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
+3 -1
View File
@@ -6,7 +6,9 @@ WORKDIR /app
RUN pip install --no-cache-dir flask RUN pip install --no-cache-dir flask
# Copy the app # Copy the app
COPY app.py . COPY app.py ascii_engine.py ./
COPY templates ./templates
COPY static ./static
# Expose port # Expose port
EXPOSE 5000 EXPOSE 5000
Binary file not shown.
Binary file not shown.
+34 -197
View File
@@ -1,205 +1,42 @@
import random from flask import Flask, jsonify, render_template, request
from flask import Flask, render_template_string, jsonify
import ascii_engine
app = Flask(__name__) app = Flask(__name__)
# Collection of ASCII art templates
ASCII_ART = [
r"""
╔══════════════════════════╗
║ RANDOM ASCII ART ║
║ generated ║
╚══════════════════════════╝
""",
r"""
_
_( )
(_, /|_ _
| / \ (_)
\| \|/
_
___| |_
|_ _| \
|_|
""",
r"""
/ \
/ _ \
/ /_\ \
/_____\
\_ _/
| |
| |
""",
r"""
┌─────────────┐
│ (◕‿◕) │
│ HAPPY! │
└─────────────┘
""",
r"""
.---.
/ \
| (.) |
\ /
'---'
""",
r"""
.-.
(o o)
| O |
\ /
`='`
""",
r"""
/\_/\
( o.o )
> ^ <
""",
r"""
___
/ _ \
| | | |
| | | |
|_| |_|
""",
r"""
┌───┐
│(◉)│
└───┘
^__^
(__)
""",
r"""
*
/|\
/ | \
/ | \
/___|___\
""",
r"""
,---.
/ @_@ \
| ^ |
\ ___ /
'---'
""",
r"""
╭──────────╮
│ SUCCESS │
╰──────────╯
\ /
\ /
\/
""",
]
HTML_TEMPLATE = """ @app.route("/")
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Random ASCII Art Generator</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Courier New', monospace;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
h1 {
color: #00ff88;
margin-bottom: 30px;
text-shadow: 0 0 10px #00ff88;
font-size: 1.8rem;
}
.art-container {
background: #0f0f23;
border: 2px solid #00ff88;
border-radius: 10px;
padding: 30px;
max-width: 90%;
overflow-x: auto;
box-shadow: 0 0 30px rgba(0, 255, 136, 0.3);
}
pre {
color: #00ff88;
font-size: 16px;
line-height: 1.2;
white-space: pre;
}
.btn {
margin-top: 30px;
padding: 15px 40px;
font-size: 18px;
font-family: 'Courier New', monospace;
background: transparent;
color: #00ff88;
border: 2px solid #00ff88;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn:hover {
background: #00ff88;
color: #0f0f23;
box-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
}
.btn:active {
transform: scale(0.95);
}
.btn:focus {
outline: none;
box-shadow: 0 0 10px #00ff88;
}
</style>
</head>
<body>
<h1>🎲 Random ASCII Art Generator</h1>
<div class="art-container">
<pre id="art">{{ art }}</pre>
</div>
<button class="btn" onclick="newArt()">Generate New Art</button>
<script>
function newArt() {
fetch('/api/random')
.then(response => response.json())
.then(data => {
document.getElementById('art').textContent = data.art;
});
}
// Allow spacebar to generate new art
document.addEventListener('keydown', function(e) {
if (e.code === 'Space') {
e.preventDefault();
newArt();
}
});
</script>
</body>
</html>
"""
@app.route('/')
def index(): def index():
art = random.choice(ASCII_ART) piece = ascii_engine.generate()
return render_template_string(HTML_TEMPLATE, art=art) return render_template(
"index.html",
piece=piece,
categories=ascii_engine.category_list(),
)
@app.route('/api/random')
@app.route("/api/random")
def api_random(): def api_random():
return jsonify({'art': random.choice(ASCII_ART)}) category = request.args.get("category")
try:
piece = ascii_engine.generate(category)
except KeyError:
return (
jsonify(
{
"error": f"unknown category '{category}'",
"categories": ascii_engine.category_list(),
}
),
400,
)
return jsonify(piece)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000) @app.route("/api/categories")
def api_categories():
return jsonify({"categories": ascii_engine.category_list()})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
+306
View File
@@ -0,0 +1,306 @@
"""Procedural ASCII art engine.
Pure stdlib. Every generator returns (title, art) where `art` is a
newline-joined block of text with no trailing whitespace.
Categories:
banner -- figlet-style pixel-font word rendering
creature -- little critters assembled from random parts
pattern -- plasma fields, cellular automata, 10 PRINT weaves, rings
scene -- procedural night landscapes (stars, moon, ridges, water)
classic -- curated hand-drawn pieces
"""
import math
import random
# ---------------------------------------------------------------------------
# banner: tiny figlet-style renderer
# ---------------------------------------------------------------------------
# 5-row bitmap font, variable width (3 cols default, M/W 5, N 4).
_FONT = {
"A": ["010", "101", "111", "101", "101"],
"B": ["110", "101", "110", "101", "110"],
"C": ["011", "100", "100", "100", "011"],
"D": ["110", "101", "101", "101", "110"],
"E": ["111", "100", "110", "100", "111"],
"F": ["111", "100", "110", "100", "100"],
"G": ["011", "100", "101", "101", "011"],
"H": ["101", "101", "111", "101", "101"],
"I": ["111", "010", "010", "010", "111"],
"J": ["001", "001", "001", "101", "010"],
"K": ["101", "110", "100", "110", "101"],
"L": ["100", "100", "100", "100", "111"],
"M": ["10001", "11011", "10101", "10001", "10001"],
"N": ["1001", "1101", "1011", "1001", "1001"],
"O": ["010", "101", "101", "101", "010"],
"P": ["110", "101", "110", "100", "100"],
"Q": ["010", "101", "101", "011", "001"],
"R": ["110", "101", "110", "101", "101"],
"S": ["011", "100", "010", "001", "110"],
"T": ["111", "010", "010", "010", "010"],
"U": ["101", "101", "101", "101", "111"],
"V": ["101", "101", "101", "101", "010"],
"W": ["10001", "10001", "10101", "11011", "10001"],
"X": ["101", "101", "010", "101", "101"],
"Y": ["101", "101", "010", "010", "010"],
"Z": ["111", "001", "010", "100", "111"],
}
_WORDS = [
"ASCII", "HELLO", "RANDOM", "PIXEL", "RETRO", "GLITCH", "NEON",
"VIBES", "MAGIC", "CODE", "HACK", "LOOP", "WAVE", "BYTE", "DATA",
"FLUX", "ECHO", "NOVA", "ZAP", "BEEP", "BOOP", "SIGNAL", "STATIC",
]
_FILLS = "█▓▒#@$"
def gen_banner():
word = random.choice(_WORDS)
fill = random.choice(_FILLS)
rows = ["", "", "", "", ""]
for i, ch in enumerate(word):
glyph = _FONT[ch]
for r in range(5):
rows[r] += "".join(fill * 2 if bit == "1" else " " for bit in glyph[r])
if i < len(word) - 1:
rows[r] += " "
width = max(len(r) for r in rows)
lines = [r.rstrip() for r in rows]
if random.random() < 0.4:
lines.append("")
lines.append(random.choice("─═¯·") * width)
return f"banner/{word.lower()}", "\n".join(lines)
# ---------------------------------------------------------------------------
# creature: parts-based critters
# ---------------------------------------------------------------------------
_EYES = "oO0*^@x=+-"
_MOUTHS = [".", "_", "~", "w", "o", "-", "v", "u"]
_SIDES = [("(", ")"), ("[", "]"), ("{", "}"), ("<", ">"), ("|", "|")]
_EARS = ["/\\_/\\", "^ ^", ".-.-.", "n n", "d b", "\\|_|/", "z z", "* *"]
_BODIES = [
["/| |\\", "(_|_|_)"],
["/ \\", "\\__ __/", " V"],
["([___])"],
["/| | |\\", " |_|_|"],
["<( )>", " / \\"],
[") (", "(__ __)", " U U"],
]
_SYLLABLES = ["blip", "zor", "mog", "fen", "pip", "tak", "nib", "gru", "yol", "wex"]
def gen_creature():
eye = random.choice(_EYES)
mouth = random.choice(_MOUTHS)
left, right = random.choice(_SIDES)
face = f"{left} {eye}{mouth}{eye} {right}"
if random.random() < 0.3: # whiskers
face = f"={face}="
lines = []
if random.random() < 0.8:
lines.append(random.choice(_EARS))
lines.append(face)
lines.extend(random.choice(_BODIES))
width = max(len(l) for l in lines)
art = "\n".join(l.center(width).rstrip() for l in lines)
name = random.choice(_SYLLABLES) + random.choice(_SYLLABLES)
return f"creature/{name}", art
# ---------------------------------------------------------------------------
# pattern: math fields
# ---------------------------------------------------------------------------
_RAMPS = [" .:-=+*#%@", " ░▒▓█", " .,'~=+:;#", " ·∙•●", " .oO@"]
def _plasma():
w, h = 52, 16
ramp = random.choice(_RAMPS)
f1 = random.uniform(0.10, 0.35)
f2 = random.uniform(0.10, 0.35)
f3 = random.uniform(0.05, 0.20)
p1 = random.uniform(0, math.tau)
p2 = random.uniform(0, math.tau)
lines = []
for y in range(h):
row = []
for x in range(w):
v = (math.sin(x * f1 + p1)
+ math.sin(y * f2 + p2)
+ math.sin((x + y) * f3))
idx = int((v + 3) / 6 * (len(ramp) - 1))
row.append(ramp[max(0, min(len(ramp) - 1, idx))])
lines.append("".join(row).rstrip())
return "pattern/plasma", "\n".join(lines)
def _rings():
w, h = 52, 17
ramp = random.choice(_RAMPS)
cx = random.uniform(w * 0.25, w * 0.75)
cy = random.uniform(h * 0.25, h * 0.75)
k = random.uniform(0.6, 1.4)
lines = []
for y in range(h):
row = []
for x in range(w):
d = math.hypot((x - cx) * 0.55, y - cy)
row.append(ramp[int(d * k) % len(ramp)])
lines.append("".join(row).rstrip())
return "pattern/rings", "\n".join(lines)
def _automaton():
rule = random.choice([30, 45, 73, 90, 110, 150])
w, gens = 56, 14
if random.random() < 0.5:
cells = [0] * w
cells[w // 2] = 1
else:
cells = [1 if random.random() < 0.3 else 0 for _ in range(w)]
on = random.choice("█▓#*")
lines = []
for _ in range(gens):
lines.append("".join(on if c else " " for c in cells).rstrip())
cells = [
(rule >> (cells[(i - 1) % w] << 2 | cells[i] << 1 | cells[(i + 1) % w])) & 1
for i in range(w)
]
return f"pattern/rule-{rule}", "\n".join(lines)
def _weave():
w, h = 48, 14
a, b = random.choice([("", ""), ("/", "\\"), ("<", ">"), ("", "_")])
p = random.uniform(0.35, 0.65)
lines = [
"".join(a if random.random() < p else b for _ in range(w))
for _ in range(h)
]
return "pattern/10print", "\n".join(lines)
def gen_pattern():
return random.choice([_plasma, _rings, _automaton, _weave])()
# ---------------------------------------------------------------------------
# scene: procedural night landscape
# ---------------------------------------------------------------------------
_SCENE_NAMES = ["ridgeline", "nightfall", "basecamp", "overlook", "cold-static", "far-north"]
def _ridge(width, lo, hi):
level = random.randint(lo, hi)
heights = []
for _ in range(width):
heights.append(level)
level = max(lo, min(hi, level + random.choice((-1, -1, 0, 0, 0, 1, 1))))
return heights
def gen_scene():
w, h = 54, 16
water_rows = 2
ground = h - water_rows
grid = [[" "] * w for _ in range(h)]
# stars
for y in range(ground - 3):
for x in range(w):
if random.random() < 0.04:
grid[y][x] = random.choice(".·+*'`")
# moon
moon = [" .-. ", "( )", " `-' "]
mx = random.randint(2, w - 8)
my = random.randint(0, 2)
for dy, line in enumerate(moon):
for dx, ch in enumerate(line):
grid[my + dy][mx + dx] = ch
# mountain ridges (back is lighter, front overwrites)
back = _ridge(w, 2, 9)
front = _ridge(w, 1, 6)
for x in range(w):
for y in range(ground - back[x], ground):
grid[y][x] = ""
for y in range(ground - front[x], ground):
grid[y][x] = ""
# water
for y in range(ground, h):
for x in range(w):
grid[y][x] = "~" if random.random() < 0.75 else " "
art = "\n".join("".join(row).rstrip() for row in grid)
return f"scene/{random.choice(_SCENE_NAMES)}", art
# ---------------------------------------------------------------------------
# classic: curated collection
# ---------------------------------------------------------------------------
_CLASSICS = [
("classic/cat", "/\\_/\\\n( o.o )\n > ^ <"),
("classic/happy-box", "┌─────────────┐\n│ (◕‿◕) │\n│ HAPPY! │\n└─────────────┘"),
("classic/owl", " ,---.\n / @_@ \\\n| ^ |\n \\ ___ /\n '---'"),
("classic/robot", " .-.\n (o o)\n | O |\n \\ /\n `='"),
("classic/rocket", " /\\\n / \\\n |==|\n | |\n /____\\\n | |\n \\ /\n \\/\n ^^^^"),
("classic/coffee", " ( (\n ) )\n ........\n | |]\n \\ /\n `----'"),
("classic/tree", " *\n /|\\\n / | \\\n / | \\\n/___|___\\\n |"),
("classic/fish", " ><(((('>\n<'))))><\n ><(((('>"),
("classic/skull", " .-\"\"\"-.\n / _ _ \\\n| (o)_(o) |\n \\ ^ /\n | - |\n `---'"),
("classic/cow", "┌───┐\n│(◉)│\n└───┘\n^__^\n(__)"),
("classic/pyramid", " / \\\n / _ \\\n/ /_\\ \\\n/_____\\"),
("classic/success", "╭──────────╮\n│ SUCCESS │\n╰──────────╯\n \\ /\n \\ /\n \\/"),
("classic/diamond", " /\\\n / \\\n / \\\n \\ /\n \\ /\n \\/\n gem"),
("classic/ghost", " .-\"\"\"-.\n| o o |\n| ^ |\n| ___ |\n'~v~v~v~'"),
]
def gen_classic():
title, art = random.choice(_CLASSICS)
return title, art
# ---------------------------------------------------------------------------
# public API
# ---------------------------------------------------------------------------
CATEGORIES = {
"banner": gen_banner,
"creature": gen_creature,
"pattern": gen_pattern,
"scene": gen_scene,
"classic": gen_classic,
}
def category_list():
return ["any"] + sorted(CATEGORIES)
def generate(category=None):
"""Generate a piece. Raises KeyError for unknown categories."""
if not category or category == "any":
category = random.choice(list(CATEGORIES))
fn = CATEGORIES[category] # KeyError propagates
title, art = fn()
lines = art.split("\n")
return {
"art": art,
"category": category,
"title": title,
"cols": max(len(l) for l in lines),
"rows": len(lines),
}
+556
View File
@@ -0,0 +1,556 @@
/* ==========================================================================
asciigen — terminal instrument UI
Themes are token sets on <html data-theme="...">.
========================================================================== */
:root {
--font-mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas,
"DejaVu Sans Mono", monospace;
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--art-base: 15px;
}
/* --------------------------------------------------------------------------
Theme tokens
-------------------------------------------------------------------------- */
/* graphite — modern dark: warm near-black, bone ink, ember accent */
:root,
[data-theme="graphite"] {
--bg: #101211;
--panel: #0a0c0b;
--ink: #e9e4d6;
--dim: #98937f;
--accent: #ff6b35;
--accent-ink: #14100c;
--line: #2b2f2c;
--dot: rgba(233, 228, 214, 0.05);
--glow: none;
--scan: 0;
--sel: rgba(255, 107, 53, 0.28);
}
/* paper — modern light: warm paper, near-black ink, persimmon accent */
[data-theme="paper"] {
--bg: #f2eee3;
--panel: #faf7ee;
--ink: #211f18;
--dim: #6e6a5c;
--accent: #b93a20;
--accent-ink: #faf7ee;
--line: #d5cfba;
--dot: rgba(33, 31, 24, 0.07);
--glow: none;
--scan: 0;
--sel: rgba(185, 58, 32, 0.2);
}
/* phosphor — P1 green terminal */
[data-theme="phosphor"] {
--bg: #041007;
--panel: #020a04;
--ink: #46e883;
--dim: #2f9e5c;
--accent: #46e883;
--accent-ink: #041007;
--line: #10381e;
--dot: rgba(70, 232, 131, 0.05);
--glow: 0 0 6px rgba(70, 232, 131, 0.55);
--scan: 0.14;
--sel: rgba(70, 232, 131, 0.25);
}
/* amber — P3 amber CRT */
[data-theme="amber"] {
--bg: #150d02;
--panel: #0e0801;
--ink: #ffb300;
--dim: #b57f14;
--accent: #ffb300;
--accent-ink: #150d02;
--line: #453006;
--dot: rgba(255, 179, 0, 0.05);
--glow: 0 0 6px rgba(255, 179, 0, 0.5);
--scan: 0.14;
--sel: rgba(255, 179, 0, 0.25);
}
/* --------------------------------------------------------------------------
Base
-------------------------------------------------------------------------- */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
height: 100%;
}
body {
min-height: 100dvh;
font-family: var(--font-mono);
font-size: 14px;
line-height: 1.5;
background-color: var(--bg);
/* subtle dot-grid texture */
background-image: radial-gradient(var(--dot) 1px, transparent 1px);
background-size: 22px 22px;
color: var(--ink);
transition: background-color 0.25s ease, color 0.25s ease;
overflow-x: hidden;
}
::selection {
background: var(--sel);
}
button {
font-family: inherit;
}
:focus-visible {
outline: 2px dashed var(--accent);
outline-offset: 3px;
}
/* --------------------------------------------------------------------------
Ambient layers: scanlines (CRT themes) + drifting glyphs
-------------------------------------------------------------------------- */
.scan {
position: fixed;
inset: 0;
z-index: 50;
pointer-events: none;
opacity: var(--scan);
background:
repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.55) 0 1px,
transparent 1px 3px
),
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.35) 100%);
transition: opacity 0.25s ease;
}
.drift {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.drift span {
position: absolute;
top: 105%;
left: var(--x);
font-size: calc(14px * var(--s));
color: var(--ink);
opacity: 0.07;
animation: rise var(--d) linear var(--dl) infinite;
text-shadow: var(--glow);
}
@keyframes rise {
to {
transform: translateY(-130vh) rotate(8deg);
}
}
/* --------------------------------------------------------------------------
Shell
-------------------------------------------------------------------------- */
.app {
position: relative;
z-index: 1;
max-width: 1060px;
margin: 0 auto;
padding: 0 clamp(16px, 4vw, 40px);
min-height: 100dvh;
display: flex;
flex-direction: column;
}
/* topbar ------------------------------------------------------------------ */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 18px 0 14px;
border-bottom: 1px solid var(--line);
}
.brand {
display: flex;
align-items: baseline;
gap: 10px;
}
.brand-mark {
color: var(--accent);
font-size: 18px;
text-shadow: var(--glow);
}
.brand-name {
font-size: 17px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
text-shadow: var(--glow);
}
.brand-tag {
font-size: 11px;
color: var(--dim);
letter-spacing: 0.08em;
}
.themes {
display: flex;
gap: 8px;
}
.theme-btn {
width: 26px;
height: 26px;
padding: 4px;
background: transparent;
border: 1px solid var(--line);
cursor: pointer;
transition: border-color 0.15s ease, transform 0.15s var(--ease-out-expo);
}
.theme-btn:hover {
transform: translateY(-2px);
border-color: var(--dim);
}
.theme-btn.is-active {
border-color: var(--accent);
}
.theme-btn .sw {
display: block;
width: 100%;
height: 100%;
}
.theme-btn[data-set-theme="graphite"] .sw { background: linear-gradient(135deg, #101211 55%, #ff6b35 55%); }
.theme-btn[data-set-theme="paper"] .sw { background: linear-gradient(135deg, #f2eee3 55%, #b93a20 55%); }
.theme-btn[data-set-theme="phosphor"] .sw { background: linear-gradient(135deg, #041007 55%, #46e883 55%); }
.theme-btn[data-set-theme="amber"] .sw { background: linear-gradient(135deg, #150d02 55%, #ffb300 55%); }
/* stage: asymmetric rail + canvas ----------------------------------------- */
.stage {
flex: 1;
display: grid;
grid-template-columns: 190px minmax(0, 1fr);
gap: clamp(24px, 5vw, 56px);
align-items: start;
padding: clamp(24px, 5vh, 48px) 0;
}
.stage > * {
min-width: 0;
}
.rail-label {
font-size: 10px;
letter-spacing: 0.35em;
text-transform: uppercase;
color: var(--dim);
margin-bottom: 12px;
}
.cats {
display: flex;
flex-direction: column;
border-left: 1px solid var(--line);
}
.cat {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
background: transparent;
border: none;
border-left: 2px solid transparent;
margin-left: -1px;
color: var(--dim);
font-size: 13px;
letter-spacing: 0.12em;
text-transform: uppercase;
text-align: left;
cursor: pointer;
transition: color 0.15s ease, transform 0.2s var(--ease-out-expo),
border-color 0.15s ease;
}
.cat:hover {
color: var(--ink);
transform: translateX(3px);
}
.cat-cursor {
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.15s ease, transform 0.2s var(--ease-out-expo);
color: var(--accent);
}
.cat.is-active {
color: var(--ink);
border-left-color: var(--accent);
}
.cat.is-active .cat-cursor,
.cat:hover .cat-cursor {
opacity: 1;
transform: translateX(0);
}
.hints {
margin-top: 36px;
font-size: 11px;
color: var(--dim);
display: flex;
flex-direction: column;
gap: 6px;
}
kbd {
font-family: inherit;
border: 1px solid var(--line);
padding: 1px 5px;
font-size: 10px;
color: var(--ink);
}
/* canvas ------------------------------------------------------------------ */
.canvas {
min-width: 0;
}
.frame {
position: relative;
background: var(--panel);
border: 1px solid var(--line);
padding: clamp(18px, 3.5vw, 40px);
transition: background-color 0.25s ease, border-color 0.25s ease;
}
.corner {
position: absolute;
width: 14px;
height: 14px;
border-color: var(--accent);
border-style: solid;
border-width: 0;
}
.c-tl { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; }
.c-tr { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; }
.c-bl { bottom: -1px; left: -1px; border-bottom-width: 2px; border-left-width: 2px; }
.c-br { bottom: -1px; right: -1px; border-bottom-width: 2px; border-right-width: 2px; }
.art-scroll {
overflow-x: auto;
display: grid;
justify-items: center;
min-height: 240px;
align-items: center;
}
#art {
font-family: var(--font-mono);
font-size: var(--art-base);
line-height: 1.25;
white-space: pre;
text-shadow: var(--glow);
}
/* status readout ----------------------------------------------------------- */
.status {
display: flex;
align-items: baseline;
gap: 10px;
padding: 10px 2px;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--dim);
}
.st-title {
color: var(--ink);
}
.st-msg {
margin-left: auto;
transition: color 0.2s ease;
}
.st-msg.is-hot {
color: var(--accent);
text-shadow: var(--glow);
}
/* actions ------------------------------------------------------------------ */
.actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 13px 26px;
font-size: 13px;
letter-spacing: 0.18em;
text-transform: uppercase;
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 0;
cursor: pointer;
transition: transform 0.18s var(--ease-out-expo), background-color 0.15s ease,
color 0.15s ease, box-shadow 0.18s ease;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 0 -1px var(--accent);
}
.btn:active {
transform: translateY(1px) scale(0.98);
box-shadow: none;
}
.btn-glyph {
transition: transform 0.2s var(--ease-out-expo);
}
.btn:hover .btn-glyph {
transform: translateX(3px);
}
.btn-primary {
background: var(--accent);
color: var(--accent-ink);
}
.btn[disabled] {
opacity: 0.55;
cursor: wait;
transform: none;
box-shadow: none;
}
/* footer ------------------------------------------------------------------- */
.foot {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 14px 0 18px;
border-top: 1px solid var(--line);
font-size: 11px;
letter-spacing: 0.08em;
color: var(--dim);
}
/* --------------------------------------------------------------------------
Responsive
-------------------------------------------------------------------------- */
@media (max-width: 760px) {
.stage {
grid-template-columns: 1fr;
gap: 20px;
padding-top: 20px;
}
.rail-label {
margin-bottom: 8px;
}
.cats {
flex-direction: row;
overflow-x: auto;
border-left: none;
border-bottom: 1px solid var(--line);
padding-bottom: 8px;
gap: 4px;
scrollbar-width: thin;
}
.cat {
border-left: none;
border-bottom: 2px solid transparent;
margin-left: 0;
white-space: nowrap;
padding: 7px 10px;
}
.cat.is-active {
border-bottom-color: var(--accent);
}
.cat:hover {
transform: none;
}
.hints {
display: none;
}
.actions {
flex-direction: column;
}
.btn {
justify-content: center;
}
.art-scroll {
min-height: 180px;
}
}
/* --------------------------------------------------------------------------
Reduced motion
-------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.drift {
display: none;
}
}
+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();
})();
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>asciigen — procedural ascii art</title>
<script>
/* Theme boot: runs before first paint, no flash. */
(function () {
var t;
try { t = localStorage.getItem("asciigen.theme"); } catch (e) {}
if (!t) {
t = (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches)
? "paper" : "graphite";
}
document.documentElement.dataset.theme = t;
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="scan" aria-hidden="true"></div>
<div class="drift" aria-hidden="true">
<span style="--x:6%; --d:34s; --dl:-4s; --s:1.1">{ }</span>
<span style="--x:16%; --d:46s; --dl:-20s; --s:0.8">#</span>
<span style="--x:28%; --d:38s; --dl:-11s; --s:1.4">&gt;_</span>
<span style="--x:41%; --d:52s; --dl:-30s; --s:0.9"></span>
<span style="--x:55%; --d:40s; --dl:-7s; --s:1.2">::</span>
<span style="--x:67%; --d:47s; --dl:-25s; --s:0.7">%</span>
<span style="--x:78%; --d:36s; --dl:-15s; --s:1.3">[]</span>
<span style="--x:90%; --d:50s; --dl:-2s; --s:1.0">\\</span>
</div>
<div class="app">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">asciigen</span>
<span class="brand-tag">/dev/random</span>
</div>
<div class="themes" role="group" aria-label="Theme">
<button class="theme-btn" data-set-theme="graphite" aria-label="Graphite theme (dark)" title="graphite"><span class="sw"></span></button>
<button class="theme-btn" data-set-theme="paper" aria-label="Paper theme (light)" title="paper"><span class="sw"></span></button>
<button class="theme-btn" data-set-theme="phosphor" aria-label="Phosphor theme (green terminal)" title="phosphor"><span class="sw"></span></button>
<button class="theme-btn" data-set-theme="amber" aria-label="Amber theme (CRT)" title="amber"><span class="sw"></span></button>
</div>
</header>
<main class="stage">
<aside class="rail">
<p class="rail-label">channel</p>
<nav class="cats" aria-label="Art category">
{% for cat in categories %}
<button class="cat{% if cat == 'any' %} is-active{% endif %}" data-cat="{{ cat }}">
<span class="cat-cursor" aria-hidden="true"></span>{{ cat }}
</button>
{% endfor %}
</nav>
<div class="hints" aria-hidden="true">
<p><kbd>space</kbd> generate</p>
<p><kbd>c</kbd> copy</p>
<p><kbd>t</kbd> theme</p>
</div>
</aside>
<section class="canvas">
<div class="frame" id="frame">
<span class="corner c-tl" aria-hidden="true"></span>
<span class="corner c-tr" aria-hidden="true"></span>
<span class="corner c-bl" aria-hidden="true"></span>
<span class="corner c-br" aria-hidden="true"></span>
<div class="art-scroll" id="artScroll" role="img" aria-label="Generated ASCII artwork">
<pre id="art">{{ piece.art }}</pre>
</div>
</div>
<div class="status" aria-live="polite">
<span class="st-title" id="stTitle">{{ piece.title }}</span>
<span class="st-sep" aria-hidden="true">·</span>
<span class="st-dims" id="stDims">{{ piece.cols }}×{{ piece.rows }}</span>
<span class="st-msg" id="stMsg">ready</span>
</div>
<div class="actions">
<button class="btn btn-primary" id="genBtn">
<span class="btn-glyph" aria-hidden="true"></span> generate
</button>
<button class="btn" id="copyBtn">
<span class="btn-glyph" aria-hidden="true"></span> <span id="copyLabel">copy</span>
</button>
</div>
</section>
</main>
<footer class="foot">
<span>procedural · stdlib python · zero deps</span>
<span class="foot-api">GET /api/random?category=…</span>
</footer>
</div>
<script>window.__initial = {{ piece | tojson }};</script>
<script src="{{ url_for('static', filename='js/app.js') }}" defer></script>
</body>
</html>