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

-
-
{{ art }}
-
- - - - - -""" - -@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 + + + + + + + +
+
+
+ + asciigen + /dev/random +
+
+ + + + +
+
+ +
+ + +
+
+ + + + + +
+ +
+ {{ piece.title }} + + {{ piece.cols }}×{{ piece.rows }} + ready +
+ +
+ + +
+
+
+ + +
+ + + + +