Add exotic palettes, pattern generator, and layout improvements

- 17 new exotic palettes: hieroglyph, cuneiform, thai, arabic, devanagari,
  ethiopic, georgian, tibetan, alchemical, dominos, mahjong, dingbats,
  playing cards, yijing, box drawing, math symbols, flora, weather
- Pattern generator with 10 types: plasma, mandelbrot, spiral, waves,
  nebula, kaleidoscope, aurora, lava, crystals, fractal_tree
- Random pattern button with rainbow gradient
- Mode tabs (Image / Patterns) in controls bar
- Preview area now fills full viewport (header/footer hidden)
- Slimmer controls bar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-02 02:13:32 +00:00
parent ebd4b12628
commit b1874eace7
5 changed files with 423 additions and 11 deletions

30
app.py
View File

@ -11,7 +11,8 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from render import (
PALETTES, image_to_art, gif_to_art, is_animated_gif,
PALETTES, PATTERN_TYPES, image_to_art, gif_to_art, is_animated_gif,
generate_pattern,
)
app = FastAPI(title="ASCII Art Generator", docs_url=None, redoc_url=None)
@ -31,6 +32,7 @@ async def index(request: Request):
"request": request,
"palettes": list(PALETTES.keys()),
"palette_previews": {k: v[:12] for k, v in PALETTES.items()},
"patterns": PATTERN_TYPES,
})
@ -47,6 +49,32 @@ async def provision_space(request: Request):
return {"status": "ok", "space": space, "message": f"rcreate space '{space}' ready"}
@app.post("/api/pattern")
async def render_pattern(
pattern: str = Form("plasma"),
width: int = Form(200),
height: int = Form(80),
palette: str = Form("mystic"),
seed: str = Form(""),
output_format: str = Form("html"),
):
width = max(20, min(500, width))
height = max(10, min(250, height))
if pattern not in PATTERN_TYPES:
pattern = "plasma"
if palette not in PALETTES:
palette = "mystic"
seed_val = int(seed) if seed.isdigit() else None
art = generate_pattern(
pattern_type=pattern, width=width, height=height,
palette_name=palette, output_format=output_format, seed=seed_val,
)
if output_format == "plain":
return PlainTextResponse(art)
return HTMLResponse(art)
@app.get("/api/palettes")
async def get_palettes():
return {

243
render.py
View File

@ -7,6 +7,8 @@ with true-color ANSI output, Floyd-Steinberg dithering, and multiple output form
"""
import argparse
import math
import random
from html import escape as html_escape
from PIL import Image, ImageSequence
@ -32,6 +34,25 @@ PALETTES = {
"shades": " ░░▒▒▓▓██",
"geometric": " .·˙∙•●○◌◦◯⊙⊚◐◑◒◓◔◕◖◗◍◎◉⦿⊛⊜⊝✦✧★✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋",
"kanji": " 一二三四五六七八九十百千万丈与世中丸主乃久乗乙九了事二人仁今仏仕他付代令以仮仰仲件任企伏伐休会伝似位低住佐体何余作佳使例侍供依価侮侯侵便係促俊俗保信修俳俵俸倉個倍倒候借値倫倹偉偏停健側偵偶傍傑傘備催債傷傾僅働像僕僚僧儀億儒元兄充兆先光克免児入全",
# ── Exotic palettes ──
"thai": " กขฃคฅฆงจฉชซฌญฎฏฐฑฒณดตถทธนบปผฝพฟภมยรลวศษสหฬอฮ",
"arabic": " ﺎﺏﺕﺙﺝﺡﺥﺩﺫﺭﺯﺱﺵﺹﺽﻁﻅﻉﻍﻑﻕﻙﻝﻡﻥﻩﻭﻱ",
"devanagari": " ँंःअआइईउऊऋएऐओऔकखगघचछजझटठडढणतथदधनपफबभमयरलवशषसह",
"ethiopic": " ሀሁሂሃሄህሆለሉሊላሌልሎሐሑሒሓሔሕሖመሙሚማሜምሞሰሱሲሳሴስሶረሩሪራሬርሮሸሹሺሻሼሽሾቀቁቂቃቄቅቆበቡቢባቤብቦተቱቲታቴትቶ",
"georgian": " აბგდევზთიკლმნოპჟრსტუფქღყშჩცძწჭხჯჰ",
"tibetan": " ༁༂༃༄༅༆༇༈༉༊་༌།༎༏༐༑༒༓༔༕༖༗༘༙༚༛༜༝༞༟༠༡༢༣༤༥༦༧༨༩",
"hieroglyph": " 𓀀𓀁𓀂𓀃𓀄𓀅𓀆𓀇𓀈𓀉𓀊𓀋𓀌𓀍𓀎𓀏𓀐𓀑𓀒𓀓𓀔𓀕𓀖𓀗𓀘𓀙𓀚𓀛𓀜𓀝𓀞𓀟𓀠𓀡𓀢𓀣𓀤𓀥𓀦𓀧𓀨𓀩𓀪𓀫𓀬𓀭𓀮𓀯𓀰",
"cuneiform": " 𒀀𒀁𒀂𒀃𒀄𒀅𒀆𒀇𒀈𒀉𒀊𒀋𒀌𒀍𒀎𒀏𒀐𒀑𒀒𒀓𒀔𒀕𒀖𒀗𒀘𒀙𒀚𒀛𒀜𒀝𒀞𒀟𒀠𒀡𒀢𒀣𒀤𒀥𒀦𒀧𒀨𒀩𒀪𒀫𒀬𒀭",
"alchemical": " 🜀🜁🜂🜃🜄🜅🜆🜇🜈🜉🜊🜋🜌🜍🜎🜏🜐🜑🜒🜓🜔🜕🜖🜗🜘🜙🜚🜛🜜🜝🜞🜟🜠🜡🜢🜣🜤🜥🜦🜧🜨🜩🜪🜫🜬🜭🜮🜯🜰🜱🜲🜳🜴🜵🜶🜷🜸🜹🜺🜻🜼🜽🜾🜿",
"dominos": " 🁣🁤🁥🁦🁧🁨🁩🁪🁫🁬🁭🁮🁯🁰🁱🁲🁳🁴🁵🁶🁷🁸🁹🁺🁻🁼🁽🁾🁿🂀🂁🂂🂃🂄🂅🂆🂇🂈🂉🂊🂋🂌🂍🂎🂏🂐🂑🂒🂓",
"mahjong": " 🀀🀁🀂🀃🀄🀅🀆🀇🀈🀉🀊🀋🀌🀍🀎🀏🀐🀑🀒🀓🀔🀕🀖🀗🀘🀙🀚🀛🀜🀝🀞🀟🀠🀡",
"dingbats": " ✁✂✃✄✆✇✈✉✌✍✎✏✐✑✒✓✔✕✖✗✘✙✚✛✜✝✞✟✠✡✢✣✤✥✦✧✩✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❍❏❐❑❒❖❘❙❚❛❜❝❞",
"playing": " 🂡🂢🂣🂤🂥🂦🂧🂨🂩🂪🂫🂬🂭🂮🂱🂲🂳🂴🂵🂶🂷🂸🂹🂺🂻🂼🂽🂾🃁🃂🃃🃄🃅🃆🃇🃈🃉🃊🃋🃌🃍🃎🃑🃒🃓🃔🃕🃖🃗🃘🃙🃚🃛🃜🃝🃞",
"yijing": " ☰☱☲☳☴☵☶☷䷀䷁䷂䷃䷄䷅䷆䷇䷈䷉䷊䷋䷌䷍䷎䷏䷐䷑䷒䷓䷔䷕䷖䷗䷘䷙䷚䷛䷜䷝䷞䷟䷠䷡䷢䷣䷤䷥䷦䷧䷨䷩䷪䷫䷬䷭䷮䷯䷰䷱䷲䷳䷴䷵䷶䷷䷸䷹䷺䷻䷼䷽䷾䷿",
"box": " ─│┌┐└┘├┤┬┴┼╌╍╎╏═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬╭╮╯╰╱╲╳",
"math": " ∀∁∂∃∄∅∆∇∈∉∊∋∌∍∎∏∐∑−∓∔∕∖∗∘∙√∛∜∝∞∟∠∡∢∣∤∥∦∧∨∩∪∫∬∭∮∯∰∱∲∳∴∵∶∷∸∹∺∻∼∽∾∿≀≁≂≃≄≅",
"flora": " 🌱🌲🌳🌴🌵🌷🌸🌹🌺🌻🌼🌽🌾🌿🍀🍁🍂🍃🍄🍅🍆🍇🍈🍉🍊🍋🍌🍍🍎🍏🍐🍑🍒🍓",
"weather": " 🌀🌁🌂🌃🌄🌅🌆🌇🌈🌉🌊🌋🌌🌍🌎🌏🌐🌑🌒🌓🌔🌕🌖🌗🌘🌙🌚🌛🌜🌝🌞🌟🌠",
}
MAX_GIF_FRAMES = 60
@ -191,6 +212,228 @@ def is_animated_gif(image_path: str) -> bool:
return False
# ── Pattern Generators ──────────────────────────────────────────────────
PATTERN_TYPES = [
"plasma", "mandelbrot", "spiral", "waves", "nebula",
"kaleidoscope", "aurora", "lava", "crystals", "fractal_tree",
]
def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]:
"""HSV (0-1 range) to RGB (0-255)."""
if s == 0:
c = int(v * 255)
return c, c, c
h6 = h * 6.0
i = int(h6)
f = h6 - i
p = int(v * (1 - s) * 255)
q = int(v * (1 - s * f) * 255)
t = int(v * (1 - s * (1 - f)) * 255)
vi = int(v * 255)
if i == 0: return vi, t, p
if i == 1: return q, vi, p
if i == 2: return p, vi, t
if i == 3: return p, q, vi
if i == 4: return t, p, vi
return vi, p, q
def generate_pattern(
pattern_type: str = "plasma",
width: int = 200,
height: int = 80,
palette_name: str = "wingdings",
output_format: str = "html",
seed: int | None = None,
) -> str:
"""Generate a colorful pattern as Unicode art."""
if seed is None:
seed = random.randint(0, 2**31)
rng = random.Random(seed)
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
num_chars = len(chars)
# Random parameters for variety
freq1 = rng.uniform(0.02, 0.12)
freq2 = rng.uniform(0.01, 0.08)
freq3 = rng.uniform(0.03, 0.15)
phase1 = rng.uniform(0, math.tau)
phase2 = rng.uniform(0, math.tau)
phase3 = rng.uniform(0, math.tau)
hue_offset = rng.uniform(0, 1)
hue_speed = rng.uniform(0.002, 0.02)
cx = width * rng.uniform(0.3, 0.7)
cy = height * rng.uniform(0.3, 0.7)
spirals = rng.randint(2, 8)
zoom = rng.uniform(0.5, 3.0)
lines = []
for y in range(height):
line = []
for x in range(width):
# Compute value (0-1) and hue based on pattern type
if pattern_type == "plasma":
v1 = math.sin(x * freq1 + phase1)
v2 = math.sin(y * freq2 + phase2)
v3 = math.sin((x + y) * freq3 + phase3)
v4 = math.sin(math.sqrt((x - cx)**2 + (y - cy)**2) * freq1)
val = (v1 + v2 + v3 + v4) / 4.0
val = (val + 1) / 2 # normalize to 0-1
hue = (val * 3 + hue_offset + x * hue_speed) % 1.0
sat = 0.7 + 0.3 * math.sin(val * math.pi)
bri = 0.4 + 0.6 * val
elif pattern_type == "mandelbrot":
# Map to complex plane
re = (x - width * 0.65) / (width * 0.3) * zoom
im = (y - height * 0.5) / (height * 0.5) * zoom
c = complex(re, im)
z = complex(0, 0)
iteration = 0
max_iter = 80
while abs(z) < 4 and iteration < max_iter:
z = z * z + c
iteration += 1
if iteration == max_iter:
val = 0
hue = 0
sat = 0
bri = 0.05
else:
val = iteration / max_iter
hue = (val * 5 + hue_offset) % 1.0
sat = 0.9
bri = 0.3 + 0.7 * val
elif pattern_type == "spiral":
dx = x - cx
dy = (y - cy) * 2.2 # aspect correction
dist = math.sqrt(dx**2 + dy**2)
angle = math.atan2(dy, dx)
val = (math.sin(dist * freq1 * 3 - angle * spirals + phase1) + 1) / 2
hue = (angle / math.tau + dist * hue_speed + hue_offset) % 1.0
sat = 0.8 + 0.2 * math.sin(dist * 0.1)
bri = 0.3 + 0.7 * val
elif pattern_type == "waves":
v1 = math.sin(x * freq1 + y * freq2 * 0.5 + phase1)
v2 = math.sin(y * freq2 * 2 + x * freq1 * 0.3 + phase2)
v3 = math.cos(x * freq3 + phase3) * math.sin(y * freq1)
val = (v1 + v2 + v3 + 3) / 6
hue = (val * 2 + y * hue_speed * 2 + hue_offset) % 1.0
sat = 0.6 + 0.4 * abs(math.sin(val * math.pi * 2))
bri = 0.3 + 0.7 * val
elif pattern_type == "nebula":
# Layered noise-like patterns
v1 = math.sin(x * freq1 + phase1) * math.cos(y * freq2 + phase2)
v2 = math.sin((x + y) * freq3) * math.cos((x - y) * freq1 * 0.7)
v3 = math.sin(math.sqrt((x - cx)**2 + (y - cy)**2) * freq2 * 0.5 + phase3)
v4 = math.sin(x * freq2 * 1.3 + y * freq3 * 0.7 + phase1 * 2)
val = (v1 + v2 + v3 + v4 + 4) / 8
hue = (val * 4 + hue_offset + math.sin(x * 0.01) * 0.2) % 1.0
sat = 0.5 + 0.5 * val
bri = 0.1 + 0.9 * val ** 0.7
elif pattern_type == "kaleidoscope":
# Mirror and rotate
dx = x - cx
dy = (y - cy) * 2.2
angle = math.atan2(dy, dx)
dist = math.sqrt(dx**2 + dy**2)
# Fold angle into segments
segments = spirals
angle = abs(((angle % (math.tau / segments)) - math.pi / segments))
nx = dist * math.cos(angle)
ny = dist * math.sin(angle)
v1 = math.sin(nx * freq1 + phase1) * math.cos(ny * freq2 + phase2)
v2 = math.sin(dist * freq3 + phase3)
val = (v1 + v2 + 2) / 4
hue = (dist * hue_speed + val + hue_offset) % 1.0
sat = 0.7 + 0.3 * math.sin(val * math.pi)
bri = 0.2 + 0.8 * val
elif pattern_type == "aurora":
wave = math.sin(x * freq1 + phase1) * 15 + math.sin(x * freq3 + phase3) * 8
dist_from_wave = abs(y - height * 0.4 - wave)
val = max(0, 1 - dist_from_wave / (height * 0.4))
val = val ** 0.5
hue = (x * hue_speed + hue_offset + val * 0.3) % 1.0
sat = 0.6 + 0.4 * val
bri = 0.05 + 0.95 * val
elif pattern_type == "lava":
v1 = math.sin(x * freq1 + y * freq2 * 0.3 + phase1)
v2 = math.cos(y * freq2 + x * freq1 * 0.5 + phase2)
v3 = math.sin(math.sqrt((x - cx)**2 + (y - cy * 0.8)**2) * freq3)
val = (v1 * v2 + v3 + 2) / 4
val = val ** 1.5 # push towards extremes
hue = (0.0 + val * 0.12 + hue_offset * 0.1) % 1.0 # reds to oranges
sat = 0.8 + 0.2 * val
bri = 0.15 + 0.85 * val
elif pattern_type == "crystals":
# Voronoi-like
min_dist = float('inf')
min_dist2 = float('inf')
# Use fixed seed points
for i in range(12):
px = rng.uniform(0, width) if i == 0 else (seed * (i + 1) * 7919) % width
py = rng.uniform(0, height) if i == 0 else (seed * (i + 1) * 6271) % height
d = math.sqrt((x - px)**2 + ((y - py) * 2.2)**2)
if d < min_dist:
min_dist2 = min_dist
min_dist = d
elif d < min_dist2:
min_dist2 = d
# Reset rng state
rng = random.Random(seed)
_ = [rng.uniform(0, 1) for _ in range(20)] # burn values to resync
edge = min_dist2 - min_dist
val = min(1, edge / 20)
hue = (min_dist * hue_speed + hue_offset) % 1.0
sat = 0.5 + 0.5 * val
bri = 0.2 + 0.8 * val
elif pattern_type == "fractal_tree":
# Barnsley fern inspired
nx = (x - cx) / (width * 0.3)
ny = (height - y) / (height * 0.8)
v1 = math.sin(nx * 8 + phase1) * math.cos(ny * 12 + phase2)
v2 = math.sin((nx * ny) * 5 + phase3)
v3 = math.cos(nx * freq1 * 50) * math.sin(ny * freq2 * 50)
val = (v1 + v2 + v3 + 3) / 6
hue = (0.25 + val * 0.2 + ny * 0.1 + hue_offset * 0.3) % 1.0 # greens
sat = 0.5 + 0.5 * val
bri = 0.1 + 0.7 * val * (0.3 + 0.7 * max(0, 1 - abs(nx) * 0.8))
else:
val = 0.5
hue = 0.5
sat = 0.5
bri = 0.5
r, g, b = _hsv_to_rgb(hue, sat, bri)
lum = get_luminance(r, g, b)
idx = int(lum / 256 * num_chars)
idx = min(idx, num_chars - 1)
char = chars[idx]
if output_format == "html":
line.append(f'<span style="color:rgb({r},{g},{b})">{html_escape(char)}</span>')
elif output_format == "ansi":
line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m")
else:
line.append(char)
lines.append("".join(line))
return "\n".join(lines)
def render_demo(image_path: str, width: int = 70):
"""Render the same image in multiple palettes for comparison."""
from rich.console import Console

View File

@ -19,6 +19,7 @@ let currentFile = null;
let animationInterval = null;
let lastRenderedHtml = '';
let zoomScale = 1.0;
let currentMode = 'image'; // 'image' or 'pattern'
const zoomIn = document.getElementById('zoomIn');
const zoomOut = document.getElementById('zoomOut');
@ -80,6 +81,90 @@ function setFile(file) {
generateBtn.disabled = false;
}
// ── Mode Switching ──────────────────────────────
const modeTabs = document.querySelectorAll('.mode-tab');
const imageControls = document.getElementById('imageControls');
const patternControls = document.getElementById('patternControls');
const randomBtn = document.getElementById('randomBtn');
const patternHeight = document.getElementById('patternHeight');
const heightVal = document.getElementById('heightVal');
modeTabs.forEach(tab => {
tab.addEventListener('click', () => {
modeTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentMode = tab.dataset.mode;
if (currentMode === 'pattern') {
imageControls.style.display = 'none';
patternControls.style.display = 'flex';
generateBtn.style.display = 'none';
randomBtn.style.display = '';
} else {
imageControls.style.display = 'flex';
patternControls.style.display = 'none';
generateBtn.style.display = '';
randomBtn.style.display = 'none';
}
});
});
patternHeight.addEventListener('input', () => {
heightVal.textContent = patternHeight.value;
});
randomBtn.addEventListener('click', generatePattern);
async function generatePattern() {
stopAnimation();
previewArea.innerHTML = '';
spinner.style.display = 'flex';
randomBtn.disabled = true;
copyBtn.style.display = 'none';
downloadBtn.style.display = 'none';
compareBox.style.display = 'none';
let patternType = document.getElementById('patternType').value;
if (patternType === 'random') {
const types = [...document.getElementById('patternType').options]
.map(o => o.value).filter(v => v !== 'random');
patternType = types[Math.floor(Math.random() * types.length)];
}
const formData = new FormData();
formData.append('pattern', patternType);
formData.append('width', widthSlider.value);
formData.append('height', patternHeight.value);
formData.append('palette', document.getElementById('palette').value);
formData.append('output_format', 'html');
try {
const resp = await fetch('/api/pattern', { method: 'POST', body: formData });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const html = await resp.text();
lastRenderedHtml = html;
spinner.style.display = 'none';
previewArea.innerHTML = html;
copyBtn.style.display = '';
downloadBtn.style.display = '';
// Auto-fit
requestAnimationFrame(() => {
const containerWidth = previewArea.clientWidth - 24;
const artWidth = previewArea.scrollWidth;
if (artWidth > containerWidth) {
setZoom(containerWidth / artWidth);
}
});
} catch (err) {
spinner.style.display = 'none';
previewArea.innerHTML = `<p class="placeholder" style="color:#ff453a">${err.message}</p>`;
}
randomBtn.disabled = false;
}
// ── Width Slider ──────────────────────────────
widthSlider.addEventListener('input', () => {

View File

@ -45,6 +45,34 @@ header {
flex-wrap: nowrap;
}
/* ── Mode Tabs ─────────────────────────────── */
.mode-tabs {
display: flex;
gap: 2px;
background: var(--surface2);
border-radius: 6px;
padding: 2px;
}
.mode-tab {
padding: 4px 10px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-dim);
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.mode-tab:hover { color: var(--text); }
.mode-tab.active { background: var(--accent); color: #000; }
.mode-controls { display: flex; align-items: center; gap: 8px; }
/* ── Upload Zone ─────────────────────────────── */
.upload-zone {

View File

@ -16,19 +16,46 @@
<!-- Controls Bar -->
<div class="controls">
<div class="upload-zone" id="dropZone">
<span class="upload-icon">&#x1F3A8;</span>
<p><label for="fileInput" class="browse-link">Upload image</label></p>
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif,image/webp" hidden>
<!-- Mode tabs -->
<div class="mode-tabs">
<button class="mode-tab active" data-mode="image">&#x1F5BC; Image</button>
<button class="mode-tab" data-mode="pattern">&#x2728; Patterns</button>
</div>
<div id="fileInfo" class="file-info" style="display:none">
<img id="thumbPreview" class="thumb">
<div>
<span id="fileName"></span>
<button id="clearFile" class="btn-small" title="Clear">&times;</button>
<!-- Image mode controls -->
<div id="imageControls" class="mode-controls">
<div class="upload-zone" id="dropZone">
<span class="upload-icon">&#x1F3A8;</span>
<p><label for="fileInput" class="browse-link">Upload</label></p>
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif,image/webp" hidden>
</div>
<div id="fileInfo" class="file-info" style="display:none">
<img id="thumbPreview" class="thumb">
<div>
<span id="fileName"></span>
<button id="clearFile" class="btn-small" title="Clear">&times;</button>
</div>
</div>
</div>
<!-- Pattern mode controls -->
<div id="patternControls" class="mode-controls" style="display:none">
<div class="setting">
<label for="patternType">Pattern</label>
<select id="patternType">
{% for p in patterns %}
<option value="{{ p }}">{{ p }}</option>
{% endfor %}
<option value="random">&#x1F3B2; random</option>
</select>
</div>
<div class="setting">
<label for="patternHeight">H <span id="heightVal">80</span></label>
<input type="range" id="patternHeight" min="20" max="200" value="80">
</div>
</div>
<!-- Shared controls -->
<div class="settings-grid">
<div class="setting">
<label for="palette">Palette</label>
@ -41,7 +68,7 @@
</select>
</div>
<div class="setting">
<label for="width">Width <span id="widthVal">200</span></label>
<label for="width">W <span id="widthVal">200</span></label>
<input type="range" id="width" min="40" max="500" value="200">
</div>
<div class="setting">
@ -58,6 +85,7 @@
</div>
<button id="generateBtn" class="btn-generate" disabled>Generate</button>
<button id="randomBtn" class="btn-generate" style="display:none;background:linear-gradient(135deg,#ff6b6b,#ffd93d,#6bff6b,#6bbbff,#bf5af2)">&#x1F3B2; Random</button>
</div>
<!-- Preview (fills viewport) -->