1005 lines
33 KiB
Plaintext
1005 lines
33 KiB
Plaintext
---
|
|
import Layout from "../layouts/Layout.astro";
|
|
import TuiSlider from "../components/TuiSlider.astro";
|
|
import TuiSegment from "../components/TuiSegment.astro";
|
|
import TuiToggle from "../components/TuiToggle.astro";
|
|
import TuiButton from "../components/TuiButton.astro";
|
|
---
|
|
|
|
<Layout title="Neko ASCII Auto-Generator">
|
|
<div class="hero-wrapper">
|
|
<!-- Background Layer: ASCII Art -->
|
|
<div class="ascii-layer">
|
|
<div id="loading">GENERATING...</div>
|
|
<pre id="ascii-result">Preparing art...</pre>
|
|
</div>
|
|
|
|
<!-- Foreground Layer: Content -->
|
|
<div class="content-layer">
|
|
<div class="max-w-container">
|
|
<main class="hero-content">
|
|
<div class="hero-text">
|
|
<h2>AUTOMATED<br />ASCII<br />SYNTHESIS</h2>
|
|
<p class="tagline">
|
|
Real-time image-to-text conversion engine.
|
|
</p>
|
|
</div>
|
|
</main>
|
|
|
|
<footer class="controls-footer">
|
|
<div id="tui-controls">
|
|
<!-- Sliders Section -->
|
|
<div class="control-panel-section sliders-section">
|
|
<div class="section-header">ADJUSTMENTS</div>
|
|
<div class="sliders-grid">
|
|
<TuiSlider
|
|
id="exposure"
|
|
label="EXP"
|
|
min={0}
|
|
max={3}
|
|
step={0.1}
|
|
value={1.0}
|
|
title="Exposure / Brightness"
|
|
/>
|
|
<TuiSlider
|
|
id="contrast"
|
|
label="CON"
|
|
min={0}
|
|
max={3}
|
|
step={0.1}
|
|
value={1.0}
|
|
title="Contrast"
|
|
/>
|
|
<TuiSlider
|
|
id="saturation"
|
|
label="SAT"
|
|
min={0}
|
|
max={3}
|
|
step={0.1}
|
|
value={1.2}
|
|
title="Saturation"
|
|
/>
|
|
<TuiSlider
|
|
id="gamma"
|
|
label="GAM"
|
|
min={0}
|
|
max={3}
|
|
step={0.1}
|
|
value={1.0}
|
|
title="Gamma Correction"
|
|
/>
|
|
<TuiSlider
|
|
id="overlayStrength"
|
|
label="OVL"
|
|
min={0}
|
|
max={1}
|
|
step={0.1}
|
|
value={0.3}
|
|
title="Overlay Blend Strength"
|
|
/>
|
|
<TuiSlider
|
|
id="resolution"
|
|
label="RES"
|
|
min={0.5}
|
|
max={2}
|
|
step={0.1}
|
|
value={1.0}
|
|
title="Resolution Scale"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Divider -->
|
|
<div class="control-panel-divider"></div>
|
|
|
|
<!-- Toggles & Segments Section -->
|
|
<div class="control-panel-section toggles-section">
|
|
<div class="section-header">EFFECTS</div>
|
|
<div class="toggles-row">
|
|
<TuiToggle
|
|
id="toggle-color"
|
|
label="CLR"
|
|
title="Color Output (HTML)"
|
|
/>
|
|
<TuiToggle
|
|
id="toggle-dither"
|
|
label="DTH"
|
|
title="Floyd-Steinberg Dithering"
|
|
/>
|
|
<TuiToggle
|
|
id="toggle-denoise"
|
|
label="DNZ"
|
|
title="Denoise Pre-processing"
|
|
/>
|
|
<TuiToggle
|
|
id="toggle-edges"
|
|
label="EDG"
|
|
title="Edge Enhancement"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
class="section-header"
|
|
style="margin-top: 8px;"
|
|
>
|
|
OUTPUT
|
|
</div>
|
|
<div class="segments-row">
|
|
<TuiSegment
|
|
id="segment-invert"
|
|
label="INV"
|
|
options={["AUTO", "ON", "OFF"]}
|
|
value="AUTO"
|
|
title="Invert Colors"
|
|
/>
|
|
<TuiSegment
|
|
id="segment-charset"
|
|
label="SET"
|
|
options={[
|
|
"STD",
|
|
"EXT",
|
|
"BLK",
|
|
"MIN",
|
|
"DOT",
|
|
"SHP",
|
|
]}
|
|
value="STD"
|
|
title="Character Set"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Divider -->
|
|
<div class="control-panel-divider"></div>
|
|
|
|
<!-- Actions Section -->
|
|
<div class="control-panel-section actions-section">
|
|
<div class="section-header">ACTIONS</div>
|
|
<div class="actions-row">
|
|
<TuiButton
|
|
id="btn-reset"
|
|
label="RESET"
|
|
shortcut="R"
|
|
title="Reset to Auto-detected Settings"
|
|
/>
|
|
<TuiButton
|
|
id="btn-next"
|
|
label="NEXT"
|
|
shortcut="N"
|
|
variant="primary"
|
|
title="Load Next Image"
|
|
/>
|
|
<div
|
|
class="queue-display"
|
|
title="Buffered Images"
|
|
>
|
|
<span class="queue-label">Q:</span>
|
|
<span id="val-queue" class="queue-value"
|
|
>0</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Keyboard shortcuts hint -->
|
|
<div class="shortcuts-hint">
|
|
<span><kbd>N</kbd> Next</span>
|
|
<span><kbd>R</kbd> Reset</span>
|
|
<span><kbd>I</kbd> Invert</span>
|
|
<span><kbd>C</kbd> Color</span>
|
|
<span><kbd>D</kbd> Dither</span>
|
|
<span><kbd>E</kbd> Edges</span>
|
|
<span><kbd>S</kbd> Charset</span>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
import {
|
|
AsciiGenerator,
|
|
autoTuneImage,
|
|
CHAR_SETS,
|
|
} from "../scripts/ascii.js";
|
|
import { fetchRandomAnimeImage } from "../scripts/anime-api.js";
|
|
|
|
const generator = new AsciiGenerator();
|
|
|
|
// State
|
|
let currentImgUrl: string | null = null;
|
|
let currentSettings = {
|
|
exposure: 1.0,
|
|
contrast: 1.0,
|
|
saturation: 1.2,
|
|
gamma: 1.0,
|
|
invert: false,
|
|
color: false,
|
|
dither: false,
|
|
denoise: false,
|
|
enhanceEdges: false,
|
|
overlayStrength: 0.3,
|
|
|
|
resolution: 1.0,
|
|
charSet: "standard",
|
|
};
|
|
let invertMode = "auto"; // 'auto', 'on', 'off'
|
|
let detectedInvert = false;
|
|
let detectedSettings: any = {}; // Store auto-detected settings
|
|
|
|
// DOM Elements
|
|
const asciiResult = document.getElementById(
|
|
"ascii-result",
|
|
) as HTMLPreElement;
|
|
const loadingIndicator = document.getElementById(
|
|
"loading",
|
|
) as HTMLDivElement;
|
|
|
|
if (!asciiResult || !loadingIndicator) {
|
|
throw new Error("Critical UI elements missing");
|
|
}
|
|
|
|
// Charset key mapping (short to full)
|
|
const charSetKeyMap: Record<string, string> = {
|
|
STD: "standard",
|
|
EXT: "extended",
|
|
BLK: "blocks",
|
|
MIN: "minimal",
|
|
DOT: "dots",
|
|
SHP: "shapes",
|
|
};
|
|
const charSetReverseMap: Record<string, string> = Object.fromEntries(
|
|
Object.entries(charSetKeyMap).map(([k, v]) => [v, k]),
|
|
);
|
|
|
|
// Update UI to reflect current settings using new components
|
|
function updateUI() {
|
|
// Update sliders
|
|
const sliderIds = [
|
|
"exposure",
|
|
"contrast",
|
|
"saturation",
|
|
"gamma",
|
|
"overlayStrength",
|
|
"resolution",
|
|
];
|
|
sliderIds.forEach((id) => {
|
|
const input = document.getElementById(id) as HTMLInputElement;
|
|
const valueDisplay = document.getElementById(`val-${id}`);
|
|
if (input && currentSettings[id] !== undefined) {
|
|
input.value = String(currentSettings[id]);
|
|
input.dispatchEvent(new Event("input"));
|
|
}
|
|
});
|
|
|
|
// Update toggles
|
|
(window as any).updateToggleState?.(
|
|
"toggle-color",
|
|
currentSettings.color,
|
|
);
|
|
(window as any).updateToggleState?.(
|
|
"toggle-dither",
|
|
currentSettings.dither,
|
|
);
|
|
(window as any).updateToggleState?.(
|
|
"toggle-denoise",
|
|
currentSettings.denoise,
|
|
);
|
|
(window as any).updateToggleState?.(
|
|
"toggle-edges",
|
|
currentSettings.enhanceEdges,
|
|
);
|
|
|
|
// Update segments
|
|
const invertValue =
|
|
invertMode === "auto"
|
|
? "AUTO"
|
|
: currentSettings.invert
|
|
? "ON"
|
|
: "OFF";
|
|
(window as any).updateSegmentValue?.("segment-invert", invertValue);
|
|
|
|
const charSetShort =
|
|
charSetReverseMap[currentSettings.charSet] || "STD";
|
|
(window as any).updateSegmentValue?.(
|
|
"segment-charset",
|
|
charSetShort,
|
|
);
|
|
|
|
// Update queue status
|
|
updateQueueStatus();
|
|
}
|
|
|
|
function updateQueueStatus() {
|
|
const queueEl = document.getElementById("val-queue");
|
|
if (queueEl) {
|
|
queueEl.textContent = imageQueue.length.toString();
|
|
}
|
|
}
|
|
|
|
function resetToAutoSettings() {
|
|
if (Object.keys(detectedSettings).length > 0) {
|
|
invertMode = "auto";
|
|
detectedInvert = detectedSettings.invert ?? false;
|
|
currentSettings = {
|
|
...currentSettings,
|
|
...detectedSettings,
|
|
resolution: currentSettings.resolution, // Keep resolution
|
|
color: false, // Reset color to off
|
|
};
|
|
currentSettings.invert = detectedInvert;
|
|
updateUI();
|
|
generate();
|
|
}
|
|
}
|
|
|
|
async function generate() {
|
|
if (!currentImgUrl) return;
|
|
|
|
// Dynamic sizing logic to fit screen
|
|
const fontAspectRatio = 0.55;
|
|
const marginRatio = 0.2;
|
|
const screenW = window.innerWidth;
|
|
const screenH = window.innerHeight;
|
|
|
|
// Available space
|
|
const availW = screenW * (1 - marginRatio);
|
|
|
|
// We need to determine optimal Width (cols) and Font Size (px)
|
|
let widthCols = screenW > 1000 ? 200 : 100; // Base resolution
|
|
|
|
// Calculate resulting height
|
|
const imgEl = await resolveImage(currentImgUrl);
|
|
const imgRatio = imgEl.width / imgEl.height;
|
|
let heightRows = widthCols / (imgRatio / fontAspectRatio);
|
|
|
|
// Refined sizing logic (legacy-inspired)
|
|
widthCols = Math.floor(availW / 6); // Assuming ~6px char width
|
|
|
|
// Apply resolution scaling
|
|
widthCols = Math.floor(widthCols * currentSettings.resolution);
|
|
|
|
if (widthCols > 300) widthCols = 300; // Cap to prevent crashing
|
|
if (widthCols < 40) widthCols = 40;
|
|
|
|
heightRows = widthCols / (imgRatio / fontAspectRatio);
|
|
|
|
try {
|
|
const result = await generator.generate(imgEl, {
|
|
width: widthCols,
|
|
height: Math.floor(heightRows),
|
|
...currentSettings,
|
|
});
|
|
|
|
// Handle color output (returns object) vs plain text (returns string)
|
|
if (typeof result === "object" && result.isHtml) {
|
|
asciiResult.innerHTML = result.output;
|
|
} else {
|
|
asciiResult.textContent = result as string;
|
|
}
|
|
|
|
// Auto-fit font size
|
|
const sizeW = (screenW * 0.9) / (widthCols * fontAspectRatio);
|
|
const sizeH = (screenH * 0.9) / heightRows;
|
|
const bestSize = Math.min(sizeW, sizeH);
|
|
|
|
asciiResult.style.fontSize = `${Math.max(4, bestSize).toFixed(2)}px`;
|
|
asciiResult.style.opacity = "1";
|
|
} catch (e) {
|
|
console.error("Render error", e);
|
|
}
|
|
}
|
|
|
|
// Queue System
|
|
const imageQueue: { data: any; imgElement: HTMLImageElement }[] = [];
|
|
const TARGET_QUEUE_SIZE = 3;
|
|
let isFetchingQueue = false;
|
|
|
|
async function fillQueue() {
|
|
if (isFetchingQueue || imageQueue.length >= TARGET_QUEUE_SIZE)
|
|
return;
|
|
// Prevent filling if document is hidden to save bandwidth
|
|
if (document.hidden) return;
|
|
|
|
isFetchingQueue = true;
|
|
|
|
try {
|
|
// Fetch one by one until full
|
|
while (imageQueue.length < TARGET_QUEUE_SIZE) {
|
|
const data = await fetchRandomAnimeImage();
|
|
// Preload
|
|
const img = await resolveImage(data.url);
|
|
// Store
|
|
imageQueue.push({
|
|
data: data,
|
|
imgElement: img,
|
|
});
|
|
// Brief pause to be nice to API
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
} catch (e) {
|
|
console.error("Queue fill error", e);
|
|
} finally {
|
|
isFetchingQueue = false;
|
|
}
|
|
}
|
|
|
|
let retryCount = 0;
|
|
const MAX_RETRIES = 3;
|
|
|
|
async function loadNewImage() {
|
|
try {
|
|
let suggestions: any;
|
|
|
|
// If queue is empty, show loading and wait for fetch
|
|
if (imageQueue.length === 0) {
|
|
loadingIndicator.style.display = "block";
|
|
asciiResult.style.opacity = "0.3";
|
|
const data = await fetchRandomAnimeImage();
|
|
const img = await resolveImage(data.url);
|
|
currentImgUrl = data.url;
|
|
|
|
suggestions = autoTuneImage(img, data.meta);
|
|
} else {
|
|
// Pop from queue
|
|
const nextItem = imageQueue.shift()!;
|
|
currentImgUrl = nextItem.data.url;
|
|
|
|
suggestions = autoTuneImage(
|
|
nextItem.imgElement as HTMLImageElement,
|
|
nextItem.data.meta,
|
|
);
|
|
|
|
// Trigger refill in background
|
|
fillQueue();
|
|
}
|
|
|
|
// Reset auto mode and apply auto-detected settings
|
|
invertMode = "auto";
|
|
detectedInvert = suggestions.invert;
|
|
detectedSettings = suggestions;
|
|
currentSettings = {
|
|
...currentSettings,
|
|
...suggestions,
|
|
// Keep resolution as is
|
|
resolution: currentSettings.resolution,
|
|
// Keep manual toggles if they were set
|
|
color: currentSettings.color,
|
|
};
|
|
currentSettings.invert = detectedInvert;
|
|
retryCount = 0; // Reset retries on success
|
|
|
|
updateUI();
|
|
await generate();
|
|
|
|
loadingIndicator.style.display = "none";
|
|
asciiResult.style.opacity = "1";
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (retryCount < MAX_RETRIES) {
|
|
retryCount++;
|
|
asciiResult.textContent = `SIGNAL LOST. RETRYING (${retryCount}/${MAX_RETRIES})...`;
|
|
setTimeout(loadNewImage, 2000);
|
|
} else {
|
|
asciiResult.textContent = "SIGNAL LOST. PLEASE REFRESH.";
|
|
loadingIndicator.style.display = "none";
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveImage(src: string): Promise<HTMLImageElement> {
|
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
const img = new Image();
|
|
img.crossOrigin = "Anonymous";
|
|
img.src = src;
|
|
img.onload = () => resolve(img);
|
|
img.onerror = reject;
|
|
});
|
|
}
|
|
|
|
// ============= NEW COMPONENT EVENT LISTENERS =============
|
|
|
|
// Slider change events
|
|
const sliderIds = [
|
|
"exposure",
|
|
"contrast",
|
|
"saturation",
|
|
"gamma",
|
|
"overlayStrength",
|
|
"resolution",
|
|
];
|
|
sliderIds.forEach((id) => {
|
|
const input = document.getElementById(id) as HTMLInputElement;
|
|
if (input) {
|
|
input.addEventListener("input", () => {
|
|
currentSettings[id] = parseFloat(input.value);
|
|
generate();
|
|
});
|
|
}
|
|
});
|
|
|
|
// Toggle change events - use event delegation to catch events from dynamically initialized toggles
|
|
document.body.addEventListener("toggle-change", (e: any) => {
|
|
const target = e.target as HTMLElement;
|
|
if (!target) return;
|
|
|
|
const toggleId = target.id;
|
|
const checked = e.detail?.checked;
|
|
|
|
switch (toggleId) {
|
|
case "toggle-color":
|
|
currentSettings.color = checked;
|
|
generate();
|
|
break;
|
|
case "toggle-dither":
|
|
currentSettings.dither = checked;
|
|
generate();
|
|
break;
|
|
case "toggle-denoise":
|
|
currentSettings.denoise = checked;
|
|
generate();
|
|
break;
|
|
case "toggle-edges":
|
|
currentSettings.enhanceEdges = checked;
|
|
generate();
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Segment change events
|
|
document
|
|
.getElementById("segment-invert")
|
|
?.addEventListener("segment-change", (e: any) => {
|
|
const value = e.detail.value;
|
|
if (value === "AUTO") {
|
|
invertMode = "auto";
|
|
currentSettings.invert = detectedInvert;
|
|
} else if (value === "ON") {
|
|
invertMode = "on";
|
|
currentSettings.invert = true;
|
|
} else {
|
|
invertMode = "off";
|
|
currentSettings.invert = false;
|
|
}
|
|
generate();
|
|
});
|
|
|
|
document
|
|
.getElementById("segment-charset")
|
|
?.addEventListener("segment-change", (e: any) => {
|
|
const shortKey = e.detail.value;
|
|
currentSettings.charSet = charSetKeyMap[shortKey] || "standard";
|
|
generate();
|
|
});
|
|
|
|
// Action button events
|
|
document.getElementById("btn-reset")?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
resetToAutoSettings();
|
|
});
|
|
|
|
document.getElementById("btn-next")?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
loadNewImage();
|
|
});
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
// Ignore if user is typing in an input
|
|
if (
|
|
e.target instanceof HTMLInputElement ||
|
|
e.target instanceof HTMLTextAreaElement
|
|
)
|
|
return;
|
|
|
|
switch (e.key.toLowerCase()) {
|
|
case "n": // Next image
|
|
loadNewImage();
|
|
break;
|
|
case "r": // Reset
|
|
resetToAutoSettings();
|
|
break;
|
|
case "i": // Cycle invert (AUTO -> ON -> OFF -> AUTO)
|
|
{
|
|
const invertSegment =
|
|
document.getElementById("segment-invert");
|
|
if (invertMode === "auto") {
|
|
invertMode = "on";
|
|
currentSettings.invert = true;
|
|
} else if (invertMode === "on") {
|
|
invertMode = "off";
|
|
currentSettings.invert = false;
|
|
} else {
|
|
invertMode = "auto";
|
|
currentSettings.invert = detectedInvert;
|
|
}
|
|
updateUI();
|
|
generate();
|
|
}
|
|
break;
|
|
case "c": // Toggle color
|
|
currentSettings.color = !currentSettings.color;
|
|
updateUI();
|
|
generate();
|
|
break;
|
|
case "d": // Toggle dither
|
|
currentSettings.dither = !currentSettings.dither;
|
|
updateUI();
|
|
generate();
|
|
break;
|
|
case "e": // Toggle edges
|
|
currentSettings.enhanceEdges =
|
|
!currentSettings.enhanceEdges;
|
|
updateUI();
|
|
generate();
|
|
break;
|
|
case "s": // Cycle charset
|
|
{
|
|
const keys = Object.keys(CHAR_SETS);
|
|
const idx = keys.indexOf(currentSettings.charSet);
|
|
const nextIdx = (idx + 1) % keys.length;
|
|
currentSettings.charSet = keys[nextIdx];
|
|
updateUI();
|
|
generate();
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Resize handler
|
|
let resizeTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
window.addEventListener("resize", () => {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(generate, 200);
|
|
});
|
|
|
|
// Periodic queue status update
|
|
setInterval(updateQueueStatus, 1000);
|
|
|
|
// Init
|
|
loadNewImage().then(() => {
|
|
fillQueue(); // Start filling queue after first load
|
|
});
|
|
</script>
|
|
</Layout>
|
|
|
|
<style>
|
|
/* Layout Wrapper */
|
|
.hero-wrapper {
|
|
position: relative;
|
|
width: 100vw;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* BACKGROUND LAYER */
|
|
.ascii-layer {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
z-index: 0; /* Behind everything */
|
|
display: flex; /* Original centering logic */
|
|
justify-content: center;
|
|
align-items: center;
|
|
pointer-events: none; /* Let clicks pass through to body/script */
|
|
opacity: 0.6; /* Slight fade to let text pop */
|
|
transition: opacity 0.5s ease;
|
|
}
|
|
|
|
#ascii-result {
|
|
font-size: 8px; /* Dynamic but starts here */
|
|
line-height: 1;
|
|
white-space: pre;
|
|
color: var(--text-color);
|
|
/* If specific alignment needed for hero: */
|
|
transform-origin: center;
|
|
}
|
|
|
|
#loading {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
font-family: var(--font-mono);
|
|
color: #fff;
|
|
font-size: 1.5rem;
|
|
display: none;
|
|
z-index: 10;
|
|
}
|
|
|
|
/* FOREGROUND LAYER */
|
|
.content-layer {
|
|
position: relative;
|
|
z-index: 1; /* Above ASCII */
|
|
width: 100%;
|
|
height: 100%;
|
|
background: radial-gradient(
|
|
circle at center,
|
|
rgba(0, 0, 0, 0) 0%,
|
|
rgba(0, 0, 0, 0.4) 80%
|
|
); /* Subtle vignette */
|
|
pointer-events: none; /* Let clicks pass for image regen */
|
|
}
|
|
|
|
.max-w-container {
|
|
width: 100%;
|
|
max-width: 1440px;
|
|
margin: 0 auto;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
/* Make interactive elements clickable again */
|
|
.site-header,
|
|
.controls-footer,
|
|
.hero-text {
|
|
pointer-events: auto;
|
|
}
|
|
|
|
/* Don't block clicks on pure layout areas if they are empty,
|
|
but hero-text might block image regen click if big.
|
|
Let's keep hero-text pointer-events auto only on text selection?
|
|
Actually user wants click anywhere to regen.
|
|
Let's make sure 'a' and 'button' are clickable, anything else triggers regen via body listener. */
|
|
.hero-text {
|
|
pointer-events: none; /* Let clicks through text area */
|
|
}
|
|
|
|
/* Center Hero Text */
|
|
.hero-content {
|
|
padding: 2rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
flex-grow: 1;
|
|
}
|
|
|
|
.hero-text h2 {
|
|
font-size: clamp(3rem, 8vw, 6rem);
|
|
line-height: 0.9;
|
|
margin: 0;
|
|
font-weight: 900;
|
|
opacity: 0.9;
|
|
text-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
|
|
mix-blend-mode: difference; /* Cool effect over ASCII */
|
|
color: #fff; /* Make it pop against the blend */
|
|
}
|
|
|
|
.tagline {
|
|
margin-top: 1rem;
|
|
font-size: 1.2rem;
|
|
opacity: 0.8;
|
|
max-width: 400px;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
padding: 5px;
|
|
}
|
|
|
|
/* Footer / Controls */
|
|
.controls-footer {
|
|
padding: 1.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
position: relative;
|
|
}
|
|
|
|
#tui-controls {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
gap: 0;
|
|
background: rgba(0, 0, 0, 0.95);
|
|
border: 1px solid var(--text-color);
|
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.8);
|
|
max-width: 100%;
|
|
}
|
|
|
|
.control-panel-section {
|
|
padding: 12px 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.control-panel-divider {
|
|
width: 1px;
|
|
background: linear-gradient(
|
|
to bottom,
|
|
transparent 0%,
|
|
rgba(255, 103, 0, 0.3) 20%,
|
|
rgba(255, 103, 0, 0.3) 80%,
|
|
transparent 100%
|
|
);
|
|
align-self: stretch;
|
|
}
|
|
|
|
.section-header {
|
|
font-size: 9px;
|
|
font-weight: bold;
|
|
opacity: 0.5;
|
|
letter-spacing: 2px;
|
|
margin-bottom: 4px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.sliders-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 6px 16px;
|
|
}
|
|
|
|
.toggles-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
|
|
.segments-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
}
|
|
|
|
.actions-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.queue-display {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 11px;
|
|
opacity: 0.6;
|
|
padding: 0 8px;
|
|
border-left: 1px solid rgba(255, 103, 0, 0.2);
|
|
}
|
|
|
|
.queue-label {
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.queue-value {
|
|
font-weight: bold;
|
|
min-width: 1.5ch;
|
|
text-align: center;
|
|
}
|
|
|
|
/* Shortcuts hint */
|
|
.shortcuts-hint {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
font-size: 10px;
|
|
opacity: 0.4;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.shortcuts-hint:hover {
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.shortcuts-hint span {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.shortcuts-hint kbd {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 16px;
|
|
height: 16px;
|
|
padding: 0 4px;
|
|
font-size: 9px;
|
|
font-family: inherit;
|
|
border: 1px solid rgba(255, 103, 0, 0.4);
|
|
border-radius: 2px;
|
|
background: rgba(255, 103, 0, 0.05);
|
|
}
|
|
|
|
/* Legacy styles kept for compatibility */
|
|
.control-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.tui-btn {
|
|
background: none;
|
|
border: 1px solid #333;
|
|
color: var(--text-color);
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
font-weight: bold;
|
|
opacity: 0.7;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.tui-btn:hover {
|
|
opacity: 1;
|
|
border-color: var(--text-color);
|
|
background: rgba(255, 103, 0, 0.1);
|
|
}
|
|
|
|
.tui-val {
|
|
min-width: 3ch;
|
|
text-align: center;
|
|
display: inline-block;
|
|
font-weight: bold;
|
|
}
|
|
|
|
/* Responsive Adjustments */
|
|
@media (max-width: 900px) {
|
|
.sliders-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
#tui-controls {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.control-panel-divider {
|
|
width: 100%;
|
|
height: 1px;
|
|
background: linear-gradient(
|
|
to right,
|
|
transparent 0%,
|
|
rgba(255, 103, 0, 0.3) 20%,
|
|
rgba(255, 103, 0, 0.3) 80%,
|
|
transparent 100%
|
|
);
|
|
}
|
|
|
|
.control-panel-section {
|
|
padding: 10px 14px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.hero-text h2 {
|
|
font-size: 3rem;
|
|
}
|
|
|
|
.controls-footer {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.shortcuts-hint {
|
|
display: none; /* Hide on mobile - too cramped */
|
|
}
|
|
|
|
.toggles-row,
|
|
.segments-row {
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.actions-row {
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.control-panel-section {
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.section-header {
|
|
font-size: 8px;
|
|
}
|
|
}
|
|
</style>
|