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
+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),
}