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
|
||||
|
||||
# Copy the app
|
||||
COPY app.py .
|
||||
COPY app.py ascii_engine.py ./
|
||||
COPY templates ./templates
|
||||
COPY static ./static
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5000
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,205 +1,42 @@
|
||||
import random
|
||||
from flask import Flask, render_template_string, jsonify
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
|
||||
import ascii_engine
|
||||
|
||||
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 = """
|
||||
<!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('/')
|
||||
@app.route("/")
|
||||
def index():
|
||||
art = random.choice(ASCII_ART)
|
||||
return render_template_string(HTML_TEMPLATE, art=art)
|
||||
piece = ascii_engine.generate()
|
||||
return render_template(
|
||||
"index.html",
|
||||
piece=piece,
|
||||
categories=ascii_engine.category_list(),
|
||||
)
|
||||
|
||||
@app.route('/api/random')
|
||||
|
||||
@app.route("/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