refactor: Implement mobile-friendly control panel toggle and remove keyboard shortcuts hint.

This commit is contained in:
syntaxbullet
2026-02-10 13:02:41 +01:00
parent bb4ca0610d
commit faa9609254
10 changed files with 1160 additions and 366 deletions

View File

@@ -6,226 +6,336 @@ import TuiButton from "./TuiButton.astro";
---
<footer id="tui-controls" class="control-panel">
<div class="control-panel-content">
<!-- Sliders Section -->
<div class="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.01}
value={1.0}
title="Exposure / Brightness"
description="Adjusts the overall brightness level of the input image before processing."
/>
<TuiSlider
id="contrast"
label="CON"
min={0}
max={3}
step={0.01}
value={1.0}
title="Contrast"
description="Increases or decreases the difference between light and dark areas."
/>
<TuiSlider
id="saturation"
label="SAT"
min={0}
max={3}
step={0.01}
value={1.2}
title="Saturation"
description="Controls color intensity. Higher values make colors more vibrant in Color Mode."
/>
<TuiSlider
id="gamma"
label="GAM"
min={0}
max={3}
step={0.01}
value={1.0}
title="Gamma Correction"
description="Non-linear brightness adjustment. useful for correcting washed-out or too dark images."
/>
<TuiSlider
id="overlayStrength"
label="OVL"
min={0}
max={1}
step={0.01}
value={0.3}
title="Overlay Blend Strength"
description="Blends the original image over the ASCII output. 0 is pure ASCII, 1 is original image."
/>
<TuiSlider
id="resolution"
label="RES"
min={0.1}
max={2}
step={0.01}
value={1.0}
title="Resolution Scale"
description="Adjusts the density of characters. Higher values give more detail but may reduce performance."
/>
<TuiSlider
id="dither"
label="DTH"
min={0}
max={1}
step={0.01}
value={0}
title="Dither Strength"
description="Applies ordered dithering to simulate shading. Useful for low-contrast areas."
/>
</div>
</div>
<div class="mobile-controls-header">
<span class="mobile-controls-title">CONTROLS</span>
<svg
class="mobile-toggle-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<div class="panel-divider-v"></div>
<!-- Toggles & Segments Section -->
<div class="panel-section effects-section">
<div class="sub-section">
<div class="section-header">EFFECTS</div>
<div class="toggles-row">
<TuiToggle
id="toggle-color"
label="CLR"
title="Color Output (HTML)"
description="Toggles between monochrome text and colored HTML spans."
<div class="control-panel-inner">
<div class="control-panel-content">
<!-- Sliders Section -->
<div class="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.01}
value={1.0}
title="Exposure / Brightness"
description="Adjusts the overall brightness level of the input image before processing."
/>
<TuiToggle
id="toggle-denoise"
label="DNZ"
title="Denoise Pre-processing"
description="Applies a bilateral filter to reduce image noise while preserving edges."
<TuiSlider
id="contrast"
label="CON"
min={0}
max={3}
step={0.01}
value={1.0}
title="Contrast"
description="Increases or decreases the difference between light and dark areas."
/>
<TuiSlider
id="shadows"
label="SHD"
min={0}
max={1}
step={0.01}
value={0.0}
title="Shadow Lift"
description="Brightens dark areas to recover details in shadows."
/>
<TuiSlider
id="highlights"
label="HLT"
min={0}
max={1}
step={0.01}
value={0.0}
title="Highlight Dim"
description="Darkens bright areas to recover details in highlights."
/>
<TuiSlider
id="saturation"
label="SAT"
min={0}
max={3}
step={0.01}
value={1.2}
title="Saturation"
description="Controls color intensity. Higher values make colors more vibrant in Color Mode."
/>
<TuiSlider
id="gamma"
label="GAM"
min={0}
max={3}
step={0.01}
value={1.0}
title="Gamma Correction"
description="Non-linear brightness adjustment. useful for correcting washed-out or too dark images."
/>
<TuiSlider
id="sharpen"
label="SHP"
min={0}
max={2}
step={0.01}
value={0.0}
title="Sharpen"
description="Enhances edges and fine details using an unsharp mask filter."
/>
<TuiSlider
id="overlayStrength"
label="OVL"
min={0}
max={1}
step={0.01}
value={0.3}
title="Overlay Blend Strength"
description="Blends the original image over the ASCII output. 0 is pure ASCII, 1 is original image."
/>
<TuiSlider
id="resolution"
label="RES"
min={0.1}
max={2}
step={0.01}
value={1.0}
title="Resolution Scale"
description="Adjusts the density of characters. Higher values give more detail but may reduce performance."
/>
<TuiSlider
id="dither"
label="DTH"
min={0}
max={1}
step={0.01}
value={0}
title="Dither Strength"
description="Applies ordered dithering to simulate shading. Useful for low-contrast areas."
/>
<TuiSlider
id="edgeThreshold"
label="THR"
min={0}
max={1}
step={0.01}
value={0.5}
title="Edge Threshold"
description="Sets the sensitivity for edge detection. Higher values detect only strong edges."
/>
<TuiSlider
id="scanlines"
label="SCN"
min={0}
max={1}
step={0.01}
value={0.0}
title="Scanlines"
description="Adds CRT-style scanline effect."
/>
<TuiSlider
id="vignette"
label="VIG"
min={0}
max={1}
step={0.01}
value={0.0}
title="Vignette"
description="Darkens the corners of the image."
/>
</div>
</div>
<div class="sub-section">
<div class="section-header">OUTPUT</div>
<div class="segments-col">
<TuiSegment
id="segment-invert"
label="INV"
options={["AUTO", "ON", "OFF"]}
value="AUTO"
title="Invert Colors"
description="Inverts brightness mapping. AUTO detects dark/light mode."
/>
<TuiSegment
id="segment-edge"
label="EDG"
options={["OFF", "SPL", "SOB", "CNY"]}
value="OFF"
title="Edge Detection Mode"
description="Algorithm used to detect edges. SPL: Simple, SOB: Sobel, CNY: Canny."
/>
<TuiSegment
id="segment-charset"
label="SET"
options={["STD", "EXT", "BLK", "MIN", "DOT", "SHP"]}
value="STD"
title="Character Set"
description="The set of characters used for mapping brightness levels."
/>
<div class="panel-divider-v"></div>
<!-- Toggles & Segments Section -->
<div class="panel-section effects-section">
<div class="sub-section">
<div class="section-header">EFFECTS</div>
<div class="toggles-row">
<TuiToggle
id="toggle-color"
label="CLR"
title="Color Output (HTML)"
description="Toggles between monochrome text and colored HTML spans."
/>
<!-- Color Picker for Monochrome -->
<div
class="tui-color-btn"
title="Monochrome Tint Color"
>
<input
type="color"
id="input-mono-color"
value="#ffffff"
/>
<span id="color-swatch-display" class="color-swatch"
></span>
<span class="label">TINT</span>
</div>
<TuiToggle
id="toggle-denoise"
label="DNZ"
title="Denoise Pre-processing"
description="Applies a bilateral filter to reduce image noise while preserving edges."
/>
</div>
</div>
</div>
</div>
<div class="panel-divider-v"></div>
<!-- Right Column: Actions & Export -->
<div class="panel-section actions-section">
<div class="sub-section">
<div class="section-header">ACTIONS</div>
<div class="actions-grid">
<TuiButton
id="btn-reset"
label="RESET"
shortcut="R"
title="Reset to Auto-detected Settings"
description="Resets all sliders and toggles to their default values."
/>
<TuiButton
id="btn-next"
label="NEXT"
shortcut="N"
variant="primary"
title="Load Next Image"
description="Discards current image and loads a new one from the queue."
/>
<div
class="queue-display"
data-tooltip-title="Buffered Images"
data-tooltip-desc="Number of images pre-loaded in background queue."
>
<span class="queue-label">Q:</span>
<span id="val-queue" class="queue-value">0</span>
<div class="sub-section">
<div class="section-header">OUTPUT</div>
<div class="segments-col">
<TuiSegment
id="segment-invert"
label="INV"
options={["AUTO", "ON", "OFF"]}
value="AUTO"
title="Invert Colors"
description="Inverts brightness mapping. AUTO detects dark/light mode."
/>
<TuiSegment
id="segment-edge"
label="EDG"
options={["OFF", "SPL", "SOB", "CNY"]}
value="OFF"
title="Edge Detection Mode"
description="Algorithm used to detect edges. SPL: Simple, SOB: Sobel, CNY: Canny."
/>
<TuiSegment
id="segment-charset"
label="SET"
options={["STD", "EXT", "BLK", "MIN", "DOT", "SHP"]}
value="STD"
title="Character Set"
description="The set of characters used for mapping brightness levels."
/>
</div>
</div>
</div>
<div class="panel-divider-h"></div>
<div class="panel-divider-v"></div>
<div class="sub-section">
<div class="section-header">IMPORT / EXPORT</div>
<div class="actions-grid">
<!-- Hidden File Input -->
<input
type="file"
id="file-upload"
accept="image/*"
style="display: none;"
/>
<TuiButton
id="btn-import"
label="IMP"
title="Import Image"
description="Upload your own image from your device."
/>
<TuiButton
id="btn-save-png"
label="PNG"
title="Save as Image"
description="Download high-res PNG capture of the current view."
/>
<TuiButton
id="btn-copy-text"
label="TXT"
title="Save as Text"
description="Download raw ASCII text file."
/>
<TuiButton
id="btn-copy-html"
label="HTML"
title="Save as HTML"
description="Download colored HTML file."
/>
<!-- Right Column: Actions & Export -->
<div class="panel-section actions-section">
<div class="sub-section">
<div class="section-header">ACTIONS</div>
<div class="actions-grid">
<TuiButton
id="btn-reset"
label="RESET"
shortcut="R"
title="Reset to Auto-detected Settings"
description="Resets all sliders and toggles to their default values."
/>
<TuiButton
id="btn-next"
label="NEXT"
shortcut="N"
variant="primary"
title="Load Next Image"
description="Discards current image and loads a new one from the queue."
/>
<div
class="queue-display"
data-tooltip-title="Buffered Images"
data-tooltip-desc="Number of images pre-loaded in background queue."
>
<span class="queue-label">Q:</span>
<span id="val-queue" class="queue-value">0</span>
</div>
</div>
</div>
<div class="panel-divider-h"></div>
<div class="sub-section">
<div class="section-header">IMPORT / EXPORT</div>
<div class="actions-grid">
<!-- Hidden File Input -->
<input
type="file"
id="file-upload"
accept="image/*"
style="display: none;"
/>
<TuiButton
id="btn-import"
label="IMP"
title="Import Image"
description="Upload your own image from your device."
/>
<TuiButton
id="btn-save-png"
label="PNG"
title="Save as Image"
description="Download high-res PNG capture of the current view."
/>
<TuiButton
id="btn-copy-text"
label="TXT"
title="Save as Text"
description="Download raw ASCII text file."
/>
<TuiButton
id="btn-copy-html"
label="HTML"
title="Save as HTML"
description="Download colored HTML file."
/>
</div>
</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>
<!-- 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>
</div>
</footer>
<script>
const controlPanel = document.getElementById("tui-controls");
const toggleBtn = controlPanel?.querySelector(".mobile-controls-header");
if (controlPanel && toggleBtn) {
// Default to collapsed on mobile
if (window.innerWidth <= 1200) {
controlPanel.classList.add("collapsed");
}
toggleBtn.addEventListener("click", () => {
controlPanel.classList.toggle("collapsed");
});
}
</script>
<style>
/* Main Container matching Sidebar visual style */
.control-panel {
@@ -384,10 +494,62 @@ import TuiButton from "./TuiButton.astro";
color: #fff;
}
.mobile-controls-header {
display: none;
}
/* Responsive Design */
@media (max-width: 1200px) {
.control-panel {
padding: 0; /* Let inner content handle padding or just reset */
}
.mobile-controls-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
cursor: pointer;
background: #000;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.mobile-controls-title {
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
letter-spacing: 1px;
}
.mobile-toggle-icon {
color: rgba(255, 255, 255, 0.5);
transition: transform 0.3s ease;
}
.control-panel.collapsed .mobile-toggle-icon {
transform: rotate(-90deg);
}
.control-panel-inner {
padding: 1.5rem;
overflow: hidden;
transition:
max-height 0.4s ease,
opacity 0.4s ease,
padding 0.4s ease;
max-height: 2000px;
opacity: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.control-panel.collapsed .control-panel-inner {
max-height: 0;
opacity: 0;
padding: 0 1.5rem;
pointer-events: none;
}
.control-panel-content {
@@ -408,4 +570,55 @@ import TuiButton from "./TuiButton.astro";
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
}
}
.tui-color-btn {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
font-family: var(--font-mono);
font-size: 11px;
padding: 4px 10px;
cursor: pointer;
border-radius: 2px;
transition: all 0.2s;
height: 29px; /* Match toggle/button height approx */
box-sizing: border-box;
}
.tui-color-btn:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.08);
}
.tui-color-btn input[type="color"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
padding: 0;
border: none;
z-index: 2;
}
.color-swatch {
width: 12px;
height: 12px;
background-color: #ffffff;
border-radius: 2px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
}
.tui-color-btn .label {
font-weight: 600;
letter-spacing: 0.5px;
}
</style>

View File

@@ -3,6 +3,22 @@
---
<aside class="sidebar">
<div class="mobile-header">
<span class="mobile-brand">SYNTAXBULLET</span>
<svg
class="mobile-toggle-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<div class="sidebar-content">
<div class="brand-group">
<a href="/" class="brand-link">
@@ -10,24 +26,24 @@
</a>
<div class="brand-subtitle">
FULL STACK ENGINEER
<span class="muted">//</span>
<span class="muted">|</span>
CREATIVE TECHNOLOGIST
</div>
</div>
<p class="brand-bio">
Crafting high-performance digital experiences at the intersection of
code, art, and artificial intelligence.
engineering, design, and artificial intelligence.
</p>
<div class="sidebar-actions">
<a href="/projects" class="sidebar-link">
<span class="icon">⚡</span> PROJECTS
<a href="/" class="sidebar-link">
<span class="icon">⚡</span> GENERATE
</a>
<a href="/blog" class="sidebar-link">
<span class="icon">📝</span> BLOG
</a>
<a href="mailto:contact@syntaxbullet.me" class="sidebar-link">
<a href="mailto:me@syntaxbullet.com" class="sidebar-link">
<span class="icon">✉️</span> CONTACT
</a>
</div>
@@ -116,6 +132,10 @@
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5);
}
.mobile-header {
display: none;
}
.sidebar-content {
padding: 4rem 3rem;
display: flex;
@@ -224,13 +244,52 @@
min-width: 0;
border-right: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 3rem 0; /* Increased padding */
padding: 0; /* Remove padding from container, move to toggle */
}
.mobile-header {
display: flex; /* Show on mobile */
justify-content: space-between;
align-items: center;
padding: 1.5rem;
cursor: pointer;
background: #000;
}
.mobile-brand {
font-size: 1.25rem;
font-weight: 900;
color: #fff;
letter-spacing: -0.5px;
}
.mobile-toggle-icon {
color: rgba(255, 255, 255, 0.5);
transition: transform 0.3s ease;
}
.sidebar.collapsed .mobile-toggle-icon {
transform: rotate(-90deg);
}
.sidebar-content {
padding: 1rem 3rem; /* Increased horizontal padding */
padding: 0 3rem 3rem 3rem; /* Adjust padding */
align-items: center;
text-align: center;
overflow: hidden;
transition:
max-height 0.4s ease,
opacity 0.4s ease,
padding 0.4s ease;
max-height: 1000px; /* Arbitrary large height */
opacity: 1;
}
.sidebar.collapsed .sidebar-content {
max-height: 0;
opacity: 0;
padding: 0 3rem; /* Collapse padding */
pointer-events: none;
}
.brand-subtitle,
@@ -238,5 +297,29 @@
justify-content: center;
align-items: center;
}
/* Hide the large title in the content on mobile since we have the header,
or keep it? The user might want the full bio etc.
Let's keep the content as is, but maybe hide the "SYNTAXBULLET" big text in content if it's redundant?
Actually, the design seems to desire the big brand text. I'll leave it. */
}
</style>
<script>
const sidebar = document.querySelector(".sidebar");
const toggleBtn = document.querySelector(".mobile-header");
if (sidebar && toggleBtn) {
// Default to collapsed on mobile initially?
// User didn't specify, but usually "collapsible" implies starting collapsed or having the ability.
// Let's start expanded or collapsed? "Make... collapsible".
// I'll default to collapsed to save space as requested "mobile friendly".
if (window.innerWidth <= 1024) {
sidebar.classList.add("collapsed");
}
toggleBtn.addEventListener("click", () => {
sidebar.classList.toggle("collapsed");
});
}
</script>

View File

@@ -1,6 +1,7 @@
---
import { getEntry } from "astro:content";
import Layout from "../../layouts/Layout.astro";
import Sidebar from "../../components/Sidebar.astro";
const { slug } = Astro.params;
if (!slug) {
@@ -16,117 +17,243 @@ if (!entry) {
const { Content } = await entry.render();
---
<Layout title={entry.data.title} showScroll={true}>
<main>
<article>
<section class="h-entry">
<header>
<h1 class="p-name">{entry.data.title}</h1>
<div class="metadata">
<time
class="dt-published"
datetime={entry.data.pubDate.toISOString()}
>
{entry.data.pubDate.toISOString().slice(0, 10)}
</time>
{
entry.data.updatedDate && (
<div class="last-updated">
Last updated on{" "}
<time>
<Layout title={entry.data.title}>
<div class="split-layout">
<Sidebar />
<main class="content-workspace">
<div class="content-container">
<article class="h-entry">
<header class="post-header">
<a href="/blog" class="back-link">
&larr; Back to Blog
</a>
<h1 class="p-name">{entry.data.title}</h1>
<div class="metadata">
<span class="dt-published">
{entry.data.pubDate.toISOString().slice(0, 10)}
</span>
{
entry.data.updatedDate && (
<span class="updated-date">
&nbsp;&bull;&nbsp; Updated:{" "}
{entry.data.updatedDate
.toISOString()
.slice(0, 10)}
</time>
</div>
)
}
</span>
)
}
</div>
<div class="divider"></div>
</header>
<div class="e-content">
<Content />
</div>
</header>
<div class="e-content">
<Content />
</div>
</section>
</article>
</main>
</article>
</div>
</main>
</div>
</Layout>
<style>
main {
width: calc(100% - 2em);
/* Split Layout */
.split-layout {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg-color);
}
/* Content Workspace */
.content-workspace {
flex-grow: 1;
height: 100vh;
position: relative;
display: flex;
flex-direction: column;
background: #050505;
overflow-y: auto;
overflow-x: hidden;
}
.content-container {
max-width: 800px;
margin: 0;
padding: 2em;
width: 100%;
margin: 0 auto;
padding: 4rem 2rem;
box-sizing: border-box;
}
header {
margin-bottom: 2rem;
/* Header Styling */
.post-header {
margin-bottom: 3rem;
}
header a {
.back-link {
display: inline-block;
margin-bottom: 1rem;
color: var(--text-color);
opacity: 0.6;
font-family: var(--font-mono);
margin-bottom: 1.5rem;
font-family:
system-ui,
-apple-system,
sans-serif;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
text-decoration: none;
transition: color 0.2s;
}
header a:hover {
opacity: 1;
.back-link:hover {
color: #fff;
}
.title {
font-size: 2em;
margin: 0.25em 0 0;
}
hr {
border-top: 1px solid var(--text-color);
opacity: 0.3;
margin: 1rem 0;
h1 {
font-size: 2.5rem;
font-weight: 800;
margin: 0 0 1rem 0;
line-height: 1.2;
color: #fff;
letter-spacing: -0.5px;
}
.metadata {
font-family: var(--font-mono);
color: var(--text-color);
opacity: 0.8;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 2rem;
}
/* Markdown Styles */
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.2);
width: 100%;
}
/* Content Styling (Markdown) */
.e-content {
line-height: 1.6;
font-family: var(--font-mono); /* Keep vibe */
font-size: 1rem;
line-height: 1.8;
font-size: 1.1rem;
color: rgba(255, 255, 255, 0.9);
font-family:
system-ui,
-apple-system,
sans-serif; /* Clean reading font */
}
/* Typography Overrides */
.e-content :global(h1),
.e-content :global(h2),
.e-content :global(h3),
.e-content :global(h4) {
margin-top: 2rem;
margin-bottom: 1rem;
color: var(--text-color);
font-weight: bold;
font-family:
system-ui,
-apple-system,
sans-serif;
color: #fff;
margin-top: 3rem;
margin-bottom: 1.5rem;
line-height: 1.3;
font-weight: 700;
letter-spacing: -0.02em;
}
.e-content :global(h2) {
font-size: 1.8rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 0.5rem;
}
.e-content :global(h3) {
font-size: 1.4rem;
}
.e-content :global(p) {
margin-bottom: 1.5rem;
}
.e-content :global(a) {
color: var(--text-color);
text-decoration: underline;
color: #fff;
text-decoration: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
transition: border-color 0.2s;
}
.e-content :global(a:hover) {
border-bottom-color: #fff;
}
.e-content :global(ul),
.e-content :global(ol) {
margin-bottom: 1.5rem;
padding-left: 2rem;
}
.e-content :global(li) {
margin-bottom: 0.5rem;
}
.e-content :global(blockquote) {
border-left: 4px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.03);
margin: 2rem 0;
padding: 1rem 1.5rem;
font-style: italic;
color: rgba(255, 255, 255, 0.8);
}
.e-content :global(img) {
max-width: 100%;
height: auto;
border-radius: 8px; /* Softer corners */
margin: 2rem 0;
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Code Blocks */
.e-content :global(pre) {
background: #111 !important; /* Force override */
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px; /* Softer corners */
padding: 1.5rem;
margin: 2rem 0;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 0.9rem;
line-height: 1.5;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.e-content :global(code) {
background: #111;
padding: 2px 5px;
border-radius: 2px;
font-family: var(--font-mono);
background: rgba(255, 255, 255, 0.1);
padding: 0.2em 0.4em;
border-radius: 4px;
font-size: 0.85em;
color: #fff;
}
.e-content :global(pre) {
background: #111;
padding: 1rem;
border: 1px solid #333;
overflow-x: auto;
.e-content :global(pre code) {
background: none;
padding: 0;
color: inherit;
font-size: 1em;
}
/* Responsive */
@media (max-width: 1024px) {
.split-layout {
flex-direction: column;
overflow: hidden;
height: 100dvh;
}
.content-workspace {
height: auto;
flex-grow: 1;
overflow-y: auto;
}
}
</style>

View File

@@ -1,5 +1,6 @@
---
import Layout from "../../layouts/Layout.astro";
import Sidebar from "../../components/Sidebar.astro";
import { getCollection, type CollectionEntry } from "astro:content";
const posts = (await getCollection("blog")).sort(
@@ -8,92 +9,195 @@ const posts = (await getCollection("blog")).sort(
);
---
<Layout title="System Logs" showScroll={true}>
<main>
<section>
<ul>
{
posts.map((post: any) => (
<li>
<a href={`/blog/${post.slug}/`}>
<span class="date">
[
{post.data.pubDate
.toISOString()
.slice(0, 10)}
]
</span>
<span class="title">{post.data.title}</span>
<span class="desc">
// {post.data.description}
</span>
</a>
</li>
))
}
</ul>
</section>
<Layout title="System Logs">
<div class="split-layout">
<Sidebar />
<footer>
<p>END OF STREAM</p>
</footer>
</main>
<main class="content-workspace">
<div class="content-container">
<header class="page-header">
<h1>Blog Articles</h1>
<div class="divider"></div>
</header>
<ul class="post-list">
{
posts.map((post: any) => (
<li>
<a
href={`/blog/${post.slug}/`}
class="post-link"
>
<div class="post-meta">
<span class="date">
{post.data.pubDate
.toISOString()
.slice(0, 10)}
</span>
</div>
<div class="post-info">
<span class="title">
{post.data.title}
</span>
<span class="desc">
{post.data.description}
</span>
</div>
</a>
</li>
))
}
</ul>
<footer>
<p>&copy; {new Date().getFullYear()} Syntaxbullet</p>
</footer>
</div>
</main>
</div>
</Layout>
<style>
main {
width: 960px;
max-width: calc(100% - 2em);
margin: 0 auto;
padding: 2em 0;
/* Split Layout - Consistent with Homepage */
.split-layout {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg-color);
}
ul {
/* Content Workspace (Right Side) */
.content-workspace {
flex-grow: 1;
height: 100vh;
position: relative;
display: flex;
flex-direction: column;
background: #050505;
overflow-y: auto; /* Enable scrolling for content */
overflow-x: hidden;
}
.content-container {
max-width: 900px;
width: 100%;
margin: 0 auto;
padding: 4rem 2rem;
box-sizing: border-box;
}
/* Header Styling */
.page-header {
margin-bottom: 3rem;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
margin: 0 0 1rem 0;
letter-spacing: -0.5px;
color: #fff;
}
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.2);
width: 100%;
}
/* Post List Styling */
.post-list {
list-style-type: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
li {
margin-bottom: 1rem;
border-left: 2px solid transparent;
transition: border-left-color 0.2s;
}
li:hover {
border-left-color: var(--text-color);
}
a {
display: block;
.post-link {
display: flex;
flex-direction: column; /* Mobile first */
gap: 0.5rem;
text-decoration: none;
padding: 5px 10px;
color: var(--text-color);
font-family: var(--font-mono);
padding: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.02);
transition: all 0.2s ease;
border-radius: 4px;
}
.date {
color: rgba(255, 103, 0, 0.6);
margin-right: 1rem;
.post-link:hover {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.05);
transform: translateY(-2px);
}
.post-meta {
font-family: var(--font-mono);
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
}
.post-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.title {
font-weight: bold;
margin-right: 1rem;
font-size: 1.25rem;
font-weight: 600;
color: #fff;
}
.desc {
color: rgba(255, 103, 0, 0.4);
font-style: italic;
}
a:hover .title {
text-decoration: underline;
color: rgba(255, 255, 255, 0.7);
font-family:
system-ui,
-apple-system,
sans-serif;
font-size: 1rem;
line-height: 1.5;
}
footer {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
opacity: 0.3;
opacity: 0.5;
font-size: 0.8rem;
font-family: var(--font-mono);
color: rgba(255, 255, 255, 0.6);
}
/* Responsive */
@media (min-width: 768px) {
.post-link {
flex-direction: row;
align-items: baseline;
gap: 1.5rem;
}
.post-meta {
min-width: 120px;
text-align: right;
}
}
@media (max-width: 1024px) {
.split-layout {
flex-direction: column;
overflow: hidden; /* Prevent body scroll, use inner scrolling */
height: 100dvh;
}
.content-workspace {
height: auto;
flex-grow: 1; /* Fill remaining space */
overflow-y: auto;
}
}
</style>

View File

@@ -5,7 +5,7 @@ import Tooltip from "../components/Tooltip.astro";
import ControlPanel from "../components/ControlPanel.astro";
---
<Layout title="Syntaxbullet - Digital Wizard">
<Layout title="Syntaxbullet - Full Stack Engineer">
<div class="split-layout">
<Sidebar />
@@ -14,7 +14,7 @@ import ControlPanel from "../components/ControlPanel.astro";
<!-- Canvas Layer -->
<div class="canvas-layer">
<div id="loading">Loading...</div>
<pre id="ascii-result">Preparing art...</pre>
<pre id="ascii-result">Initializing...</pre>
<canvas id="ascii-canvas"></canvas>
</div>
@@ -104,10 +104,11 @@ import ControlPanel from "../components/ControlPanel.astro";
console.error(e);
if (retryCount < MAX_RETRIES) {
retryCount++;
asciiResult.textContent = `SIGNAL LOST. RETRYING (${retryCount}/${MAX_RETRIES})...`;
asciiResult.textContent = `Connection lost. Retrying (${retryCount}/${MAX_RETRIES})...`;
setTimeout(loadNewImage, 2000);
} else {
asciiResult.textContent = "SIGNAL LOST. PLEASE REFRESH.";
asciiResult.textContent =
"Connection failed. Please refresh.";
controller.hideLoading();
}
}
@@ -170,6 +171,7 @@ import ControlPanel from "../components/ControlPanel.astro";
image-rendering: pixelated;
opacity: 0;
transition: opacity 0.5s ease;
touch-action: none;
}
#loading {
@@ -191,11 +193,14 @@ import ControlPanel from "../components/ControlPanel.astro";
@media (max-width: 1024px) {
.split-layout {
flex-direction: column;
overflow-y: auto;
overflow: hidden; /* Prevent body scroll, use inner scrolling */
height: 100dvh; /* Dynamic viewport height */
}
.ascii-workspace {
height: 80vh; /* Give space for scroll */
height: auto;
flex-grow: 1; /* Fill remaining space */
overflow-y: auto; /* Allow scrolling inside workspace if needed */
}
}
</style>

View File

@@ -59,6 +59,12 @@ export class AsciiController {
showMagnifier: false
};
// Touch state
private lastTouchDist = 0;
private isDragging = false;
private lastTouchPos = { x: 0, y: 0 };
private resizeObserver: ResizeObserver | null = null;
// Callbacks
private onSettingsChange?: () => void;
@@ -77,6 +83,33 @@ export class AsciiController {
}
this.startRenderLoop();
this.setupEventListeners();
}
private setupEventListeners(): void {
// Resize handling
this.resizeObserver = new ResizeObserver(() => {
// Debounce resize
if (this.animFrameId) {
// We are in a loop, just toggle a flag or call safely
this.handleResize();
}
});
if (this.canvas.parentElement) {
this.resizeObserver.observe(this.canvas.parentElement);
}
// Touch events
this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
}
private handleResize(): void {
// Re-calculate grid based on new dimensions
this.calculateGrid().then(() => {
this.requestRender('all');
});
}
private getDefaultSettings(): AsciiSettings {
@@ -92,7 +125,14 @@ export class AsciiController {
edgeMode: 0,
overlayStrength: 0.3,
resolution: 1.0,
charSet: 'standard'
charSet: 'standard',
sharpen: 0,
edgeThreshold: 0.5,
shadows: 0,
highlights: 0,
scanlines: 0,
vignette: 0,
monoColor: '#ffffff'
};
}
@@ -213,7 +253,14 @@ export class AsciiController {
overlayStrength: suggestions.overlayStrength ?? this.settings.overlayStrength,
charSet: validCharSet,
resolution: this.settings.resolution,
color: this.settings.color
color: this.settings.color,
sharpen: suggestions.sharpen ?? this.settings.sharpen,
edgeThreshold: suggestions.edgeThreshold ?? this.settings.edgeThreshold,
shadows: suggestions.shadows ?? this.settings.shadows,
highlights: suggestions.highlights ?? this.settings.highlights,
scanlines: suggestions.scanlines ?? this.settings.scanlines,
vignette: suggestions.vignette ?? this.settings.vignette,
monoColor: suggestions.monoColor ?? this.settings.monoColor
};
this.onSettingsChange?.();
@@ -280,6 +327,9 @@ export class AsciiController {
let widthCols = Math.floor(availW / 6);
widthCols = Math.floor(widthCols * this.settings.resolution);
// Cap grid resolution on mobile specifically if needed,
// but current logic is mostly fine as long as resolution slider is manageable.
widthCols = Math.max(10, Math.min(1000, widthCols));
const imgEl = await this.resolveImage(this.currentImgUrl);
@@ -362,7 +412,9 @@ export class AsciiController {
this.canvas.style.width = `${finalW}px`;
this.canvas.style.height = `${finalH}px`;
const dpr = window.devicePixelRatio || 1;
// Cap DPR to improve mobile performance
const dpr = Math.min(window.devicePixelRatio || 1, 2.0);
this.canvas.width = finalW * dpr;
this.canvas.height = finalH * dpr;
}
@@ -375,7 +427,7 @@ export class AsciiController {
this.requestRender('all');
}
// ============= Zoom =============
// ============= Zoom & Touch =============
handleWheel(e: WheelEvent): void {
const rect = this.canvas.getBoundingClientRect();
@@ -401,13 +453,16 @@ export class AsciiController {
}
handleMouseMove(e: MouseEvent): void {
if ('ontouchstart' in window && (e as any).sourceCapabilities?.firesTouchEvents) return; // Ignore simulated mouse events
const rect = this.canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
this.zoomState.mousePos = { x: mx, y: my };
const wasShowing = this.zoomState.showMagnifier;
this.zoomState.showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1;
// Only show magnifier if not zoomed out completely
this.zoomState.showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1 && !('ontouchstart' in window);
if (this.zoomState.showMagnifier || wasShowing) {
this.requestRender('uniforms');
@@ -421,6 +476,74 @@ export class AsciiController {
}
}
// Touch Support
handleTouchStart(e: TouchEvent): void {
if (e.touches.length === 2) {
e.preventDefault();
this.lastTouchDist = this.getTouchDistance(e.touches);
this.isDragging = false;
} else if (e.touches.length === 1 && this.zoomState.zoom > 1.0) {
e.preventDefault();
this.isDragging = true;
this.lastTouchPos = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
}
handleTouchMove(e: TouchEvent): void {
if (e.touches.length === 2) {
e.preventDefault();
const dist = this.getTouchDistance(e.touches);
const factor = dist / this.lastTouchDist;
this.lastTouchDist = dist;
const oldZoom = this.zoomState.zoom;
this.zoomState.zoom = Math.min(Math.max(this.zoomState.zoom * factor, 1.0), 10.0);
// Center zoom between touches
const rect = this.canvas.getBoundingClientRect();
const cx = ((e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left) / rect.width;
const cy = ((e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top) / rect.height;
if (oldZoom !== this.zoomState.zoom) {
const imgX = (cx - this.zoomState.zoomCenter.x) / oldZoom + this.zoomState.zoomCenter.x;
const imgY = (cy - this.zoomState.zoomCenter.y) / oldZoom + this.zoomState.zoomCenter.y;
this.zoomState.zoomCenter.x = (imgX - cx / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom);
this.zoomState.zoomCenter.y = (imgY - cy / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom);
}
this.requestRender('uniforms');
} else if (e.touches.length === 1 && this.isDragging && this.zoomState.zoom > 1.0) {
e.preventDefault();
const curX = e.touches[0].clientX;
const curY = e.touches[0].clientY;
const dx = (curX - this.lastTouchPos.x) / this.canvas.width;
const dy = (curY - this.lastTouchPos.y) / this.canvas.height;
// Logarithmic pan speed based on zoom?
// Simple mapping: move zoomCenter opposite to drag
this.zoomState.zoomCenter.x -= dx / this.zoomState.zoom;
this.zoomState.zoomCenter.y -= dy / this.zoomState.zoom;
this.lastTouchPos = { x: curX, y: curY };
this.requestRender('uniforms');
}
}
handleTouchEnd(e: TouchEvent): void {
this.isDragging = false;
if (e.touches.length === 0 && this.zoomState.zoom <= 1.0) {
this.zoomState.zoomCenter = { x: 0.5, y: 0.5 };
this.requestRender('uniforms');
}
}
private getTouchDistance(touches: TouchList): number {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
// ============= Export =============
savePNG(): void {
@@ -499,6 +622,9 @@ export class AsciiController {
if (this.animFrameId !== null) {
cancelAnimationFrame(this.animFrameId);
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.renderer?.dispose();
this.renderer = null;
}

View File

@@ -23,6 +23,13 @@ export interface AsciiOptions {
denoise?: boolean;
fontAspectRatio?: number;
onProgress?: (progress: number) => void;
sharpen?: number;
edgeThreshold?: number;
shadows?: number;
highlights?: number;
scanlines?: number;
vignette?: number;
monoColor?: string;
}
export interface AsciiResult {
@@ -55,6 +62,13 @@ export interface AsciiSettings {
overlayStrength: number;
resolution: number;
charSet: CharSetKey;
sharpen: number;
edgeThreshold: number;
shadows: number;
highlights: number;
scanlines: number;
vignette: number;
monoColor: string;
}
// ============= Constants =============

View File

@@ -62,6 +62,7 @@ export class UIBindings {
init(): void {
this.setupSliders();
this.setupToggles();
this.setupColorInput();
this.setupSegments();
this.setupButtons();
this.setupKeyboard();
@@ -81,7 +82,7 @@ export class UIBindings {
}
// Cleanup Sliders
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const;
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights'] as const;
sliderIds.forEach(id => {
const input = document.getElementById(id) as HTMLInputElement | null;
const handler = this.sliderHandlers.get(id);
@@ -162,7 +163,7 @@ export class UIBindings {
// ============= Sliders =============
private setupSliders(): void {
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const;
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights', 'scanlines', 'vignette'] as const;
sliderIds.forEach(id => {
const input = document.getElementById(id) as HTMLInputElement | null;
@@ -225,6 +226,23 @@ export class UIBindings {
document.body.addEventListener('toggle-change', this.toggleHandler);
}
// ============= Color Input =============
private setupColorInput(): void {
const colorInput = document.getElementById('input-mono-color') as HTMLInputElement;
const colorSwatch = document.getElementById('color-swatch-display');
if (colorInput) {
colorInput.addEventListener('input', () => {
if (this.isUpdatingUI) return;
this.controller.setSetting('monoColor', colorInput.value);
if (colorSwatch) {
colorSwatch.style.backgroundColor = colorInput.value;
}
});
}
}
// ============= Segments =============
private setupSegments(): void {
@@ -475,7 +493,7 @@ export class UIBindings {
const settings = this.controller.getSettings();
// Update sliders
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const;
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights', 'scanlines', 'vignette'] as const;
sliderIds.forEach(id => {
const input = document.getElementById(id) as HTMLInputElement | null;
if (input && settings[id] !== undefined) {
@@ -509,6 +527,19 @@ export class UIBindings {
}
window.updateSegmentValue?.('segment-edge', edgeShort);
// Update color input
const colorInput = document.getElementById('input-mono-color') as HTMLInputElement;
const colorSwatch = document.getElementById('color-swatch-display');
if (colorInput && settings.monoColor) {
if (colorInput.value !== settings.monoColor) {
colorInput.value = settings.monoColor;
}
if (colorSwatch) {
colorSwatch.style.backgroundColor = settings.monoColor;
}
}
this.updateQueueDisplay();
this.isUpdatingUI = false;

View File

@@ -15,6 +15,13 @@ export interface RenderOptions {
edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny
dither?: number;
denoise?: boolean;
sharpen?: number;
edgeThreshold?: number;
shadows?: number;
highlights?: number;
scanlines?: number;
vignette?: number;
monoColor?: string;
zoom?: number;
zoomCenter?: { x: number; y: number };
mousePos?: { x: number; y: number };
@@ -52,6 +59,10 @@ export class WebGLAsciiRenderer {
}
this.gl = gl;
// Enable required extensions for advanced rendering
gl.getExtension('OES_standard_derivatives');
gl.getExtension('EXT_shader_texture_lod');
this.program = null;
this.textures = {};
this.buffers = {};
@@ -80,6 +91,8 @@ export class WebGLAsciiRenderer {
// Fragment Shader
const fsSource = `
#extension GL_OES_standard_derivatives : enable
#extension GL_EXT_shader_texture_lod : enable
precision mediump float;
varying vec2 v_texCoord;
@@ -102,6 +115,13 @@ export class WebGLAsciiRenderer {
uniform int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny
uniform float u_dither; // Dither strength 0.0 - 1.0
uniform bool u_denoise;
uniform float u_sharpen;
uniform float u_edgeThreshold;
uniform float u_shadows;
uniform float u_highlights;
uniform float u_scanlines;
uniform float u_vignette;
uniform vec3 u_monoColor;
// Zoom & Magnifier
uniform float u_zoom;
@@ -125,11 +145,26 @@ export class WebGLAsciiRenderer {
// Exposure
color *= u_exposure;
// Shadows / Highlights
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
// Shadows: lift dark areas
if (u_shadows > 0.0) {
float shadowFactor = (1.0 - luma) * u_shadows;
color = color + (vec3(1.0) - color) * shadowFactor * 0.5;
}
// Highlights: dim bright areas
if (u_highlights > 0.0) {
float highlightFactor = luma * u_highlights;
color = color * (1.0 - highlightFactor * 0.5);
}
// Contrast
color = (color - 0.5) * u_contrast + 0.5;
// Saturation
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
color = mix(vec3(luma), color, u_saturation);
// Gamma
@@ -232,6 +267,12 @@ export class WebGLAsciiRenderer {
color = getAverageColor(sampleUV, cellSize);
}
// Sharpening
if (u_sharpen > 0.0) {
vec3 blurred = getAverageColor(sampleUV, cellSize * 2.0);
color = color + (color - blurred) * u_sharpen;
}
// Edge Detection Logic
if (u_edgeMode == 1) {
// Simple Laplacian-like
@@ -244,14 +285,20 @@ export class WebGLAsciiRenderer {
vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right);
float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722));
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5);
// Use Threshold
if (edgeLum > u_edgeThreshold * 0.1) {
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5);
}
} else if (u_edgeMode == 2) {
// Sobel Gradient
vec2 sobel = sobelFilter(sampleUV, cellSize);
float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0);
// Darken edges
color = mix(color, vec3(0.0), edgeStr * 0.8);
if (edgeStr > u_edgeThreshold * 0.2) {
color = mix(color, vec3(0.0), edgeStr * 0.8);
}
} else if (u_edgeMode == 3) {
// "Canny-like" (Sobel + gradient suppression)
@@ -260,13 +307,12 @@ export class WebGLAsciiRenderer {
float angle = sobel.y;
// Non-maximum suppression (simplified)
// Check neighbors in gradient direction
vec2 dir = vec2(cos(angle), sin(angle)) * cellSize;
vec2 s1 = sobelFilter(sampleUV + dir, cellSize);
vec2 s2 = sobelFilter(sampleUV - dir, cellSize);
if (mag < s1.x || mag < s2.x || mag < 0.15) {
// Use edge threshold
if (mag < s1.x || mag < s2.x || mag < u_edgeThreshold * 0.3) { // scaled threshold
mag = 0.0;
} else {
mag = 1.0; // Strong edge
@@ -276,7 +322,7 @@ export class WebGLAsciiRenderer {
color = mix(color, vec3(0.0), mag);
}
// Apply adjustments
// Apply adjustments (Contrast, etc.)
color = adjust(color);
// Overlay blend-like effect (boost mid-contrast)
@@ -319,18 +365,48 @@ export class WebGLAsciiRenderer {
uvInCell.y * u_charSizeUV.y
);
float charAlpha = texture2D(u_atlas, atlasUV).r;
// --- MIPMAP FIX ---
// Use zoomed 'uv' for derivatives to handle zoom correctly.
// Multiply by 0.5 to bias towards sharper mipmaps (LOD bias).
vec2 gradX = dFdx(uv) * u_gridSize * u_charSizeUV * 0.5;
vec2 gradY = dFdy(uv) * u_gridSize * u_charSizeUV * 0.5;
// Use texture2DGradEXT to sample with explicit gradients, resolving cell boundary artifacts
float charAlpha = texture2DGradEXT(u_atlas, atlasUV, gradX, gradY).r;
// Loup border effect
if (u_showMagnifier) {
float edgeWidth = 0.005;
if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) {
charAlpha = 1.0;
color = vec3(1.0, 0.4039, 0.0); // Safety Orange border for the loupe
color = vec3(1.0, 1.0, 1.0); // White border for the loupe
}
}
vec3 finalColor = u_color ? color : vec3(1.0, 0.4039, 0.0);
// Vignette
if (u_vignette > 0.0) {
float dist = distance(uv, vec2(0.5));
// Smoothsoft vignette
float vig = smoothstep(0.8 + (1.0 - u_vignette) * 0.5, 0.2, dist);
charAlpha *= vig;
}
// Scanlines
if (u_scanlines > 0.0) {
// Sine wave based on grid rows
float scan = 0.5 + 0.5 * sin(uv.y * u_gridSize.y * 3.14159 * 2.0);
// Blend scanline effect based on strength
float scanEffect = mix(1.0, scan, u_scanlines * 0.5);
charAlpha *= scanEffect;
}
// Mono Color
vec3 finalColor;
if (u_color) {
finalColor = color;
} else {
finalColor = u_monoColor;
}
gl_FragColor = vec4(finalColor * charAlpha, charAlpha);
}
@@ -366,6 +442,8 @@ export class WebGLAsciiRenderer {
'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma',
'u_invert', 'u_color', 'u_overlayStrength', 'u_edgeMode',
'u_dither', 'u_denoise',
'u_sharpen', 'u_edgeThreshold', 'u_shadows', 'u_highlights',
'u_scanlines', 'u_vignette', 'u_monoColor',
'u_zoom', 'u_zoomCenter', 'u_mousePos',
'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect'
];
@@ -518,6 +596,19 @@ export class WebGLAsciiRenderer {
gl.uniform1i(u['u_edgeMode'], options.edgeMode || 0);
gl.uniform1f(u['u_dither'], options.dither || 0.0);
gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 0);
gl.uniform1f(u['u_sharpen'], options.sharpen || 0.0);
gl.uniform1f(u['u_edgeThreshold'], options.edgeThreshold || 0.5); // Default to mid
gl.uniform1f(u['u_shadows'], options.shadows || 0.0);
gl.uniform1f(u['u_highlights'], options.highlights || 0.0);
gl.uniform1f(u['u_scanlines'], options.scanlines || 0.0);
gl.uniform1f(u['u_vignette'], options.vignette || 0.0);
// Parse hex color
const hex = options.monoColor || '#ffffff';
const r = parseInt(hex.slice(1, 3), 16) / 255.0;
const g = parseInt(hex.slice(3, 5), 16) / 255.0;
const b = parseInt(hex.slice(5, 7), 16) / 255.0;
gl.uniform3f(u['u_monoColor'], r, g, b);
// Zoom & Magnifier
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);

View File

@@ -1,6 +1,6 @@
:root {
--bg-color: #000000;
--text-color: #FF6700;
--text-color: #FFFFFF;
--font-mono: 'JetBrains Mono', monospace;
}