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:
+3
-1
@@ -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.
@@ -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
@@ -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),
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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">>_</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>
|
||||||
Reference in New Issue
Block a user