diff --git a/Dockerfile b/Dockerfile
index 2044b9d..49c6d58 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc
new file mode 100644
index 0000000..7dcff75
Binary files /dev/null and b/__pycache__/app.cpython-314.pyc differ
diff --git a/__pycache__/ascii_engine.cpython-314.pyc b/__pycache__/ascii_engine.cpython-314.pyc
new file mode 100644
index 0000000..c4f2e79
Binary files /dev/null and b/__pycache__/ascii_engine.cpython-314.pyc differ
diff --git a/app.py b/app.py
index 5380b2a..b824a33 100644
--- a/app.py
+++ b/app.py
@@ -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 = """
-
-
-
-
-
- Random ASCII Art Generator
-
-
-
- 🎲 Random ASCII Art Generator
-
-
-
-
-
-
-"""
-
-@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)
diff --git a/ascii_engine.py b/ascii_engine.py
new file mode 100644
index 0000000..c6f9f58
--- /dev/null
+++ b/ascii_engine.py
@@ -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),
+ }
diff --git a/static/css/style.css b/static/css/style.css
new file mode 100644
index 0000000..77298bb
--- /dev/null
+++ b/static/css/style.css
@@ -0,0 +1,556 @@
+/* ==========================================================================
+ asciigen — terminal instrument UI
+ Themes are token sets on .
+ ========================================================================== */
+
+: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;
+ }
+}
diff --git a/static/js/app.js b/static/js/app.js
new file mode 100644
index 0000000..0cac4d6
--- /dev/null
+++ b/static/js/app.js
@@ -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();
+})();
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..5967311
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,104 @@
+
+
+
+
+
+ asciigen — procedural ascii art
+
+
+
+
+
+
+ { }
+ #
+ >_
+ ▚
+ ::
+ %
+ []
+ \\
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ piece.title }}
+ ·
+ {{ piece.cols }}×{{ piece.rows }}
+ ready
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+