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"> <footer id="tui-controls" class="control-panel">
<div class="control-panel-content"> <div class="mobile-controls-header">
<!-- Sliders Section --> <span class="mobile-controls-title">CONTROLS</span>
<div class="panel-section sliders-section"> <svg
<div class="section-header">ADJUSTMENTS</div> class="mobile-toggle-icon"
<div class="sliders-grid"> width="24"
<TuiSlider height="24"
id="exposure" viewBox="0 0 24 24"
label="EXP" fill="none"
min={0} stroke="currentColor"
max={3} stroke-width="2"
step={0.01} stroke-linecap="round"
value={1.0} stroke-linejoin="round"
title="Exposure / Brightness" >
description="Adjusts the overall brightness level of the input image before processing." <polyline points="6 9 12 15 18 9"></polyline>
/> </svg>
<TuiSlider </div>
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="panel-divider-v"></div> <div class="control-panel-inner">
<div class="control-panel-content">
<!-- Toggles & Segments Section --> <!-- Sliders Section -->
<div class="panel-section effects-section"> <div class="panel-section sliders-section">
<div class="sub-section"> <div class="section-header">ADJUSTMENTS</div>
<div class="section-header">EFFECTS</div> <div class="sliders-grid">
<div class="toggles-row"> <TuiSlider
<TuiToggle id="exposure"
id="toggle-color" label="EXP"
label="CLR" min={0}
title="Color Output (HTML)" max={3}
description="Toggles between monochrome text and colored HTML spans." step={0.01}
value={1.0}
title="Exposure / Brightness"
description="Adjusts the overall brightness level of the input image before processing."
/> />
<TuiSlider
<TuiToggle id="contrast"
id="toggle-denoise" label="CON"
label="DNZ" min={0}
title="Denoise Pre-processing" max={3}
description="Applies a bilateral filter to reduce image noise while preserving edges." 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> </div>
<div class="sub-section"> <div class="panel-divider-v"></div>
<div class="section-header">OUTPUT</div>
<div class="segments-col"> <!-- Toggles & Segments Section -->
<TuiSegment <div class="panel-section effects-section">
id="segment-invert" <div class="sub-section">
label="INV" <div class="section-header">EFFECTS</div>
options={["AUTO", "ON", "OFF"]} <div class="toggles-row">
value="AUTO" <TuiToggle
title="Invert Colors" id="toggle-color"
description="Inverts brightness mapping. AUTO detects dark/light mode." label="CLR"
/> title="Color Output (HTML)"
<TuiSegment description="Toggles between monochrome text and colored HTML spans."
id="segment-edge" />
label="EDG"
options={["OFF", "SPL", "SOB", "CNY"]} <!-- Color Picker for Monochrome -->
value="OFF" <div
title="Edge Detection Mode" class="tui-color-btn"
description="Algorithm used to detect edges. SPL: Simple, SOB: Sobel, CNY: Canny." title="Monochrome Tint Color"
/> >
<TuiSegment <input
id="segment-charset" type="color"
label="SET" id="input-mono-color"
options={["STD", "EXT", "BLK", "MIN", "DOT", "SHP"]} value="#ffffff"
value="STD" />
title="Character Set" <span id="color-swatch-display" class="color-swatch"
description="The set of characters used for mapping brightness levels." ></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>
<div class="panel-divider-v"></div> <div class="sub-section">
<div class="section-header">OUTPUT</div>
<!-- Right Column: Actions & Export --> <div class="segments-col">
<div class="panel-section actions-section"> <TuiSegment
<div class="sub-section"> id="segment-invert"
<div class="section-header">ACTIONS</div> label="INV"
<div class="actions-grid"> options={["AUTO", "ON", "OFF"]}
<TuiButton value="AUTO"
id="btn-reset" title="Invert Colors"
label="RESET" description="Inverts brightness mapping. AUTO detects dark/light mode."
shortcut="R" />
title="Reset to Auto-detected Settings" <TuiSegment
description="Resets all sliders and toggles to their default values." id="segment-edge"
/> label="EDG"
options={["OFF", "SPL", "SOB", "CNY"]}
<TuiButton value="OFF"
id="btn-next" title="Edge Detection Mode"
label="NEXT" description="Algorithm used to detect edges. SPL: Simple, SOB: Sobel, CNY: Canny."
shortcut="N" />
variant="primary" <TuiSegment
title="Load Next Image" id="segment-charset"
description="Discards current image and loads a new one from the queue." label="SET"
/> options={["STD", "EXT", "BLK", "MIN", "DOT", "SHP"]}
value="STD"
<div title="Character Set"
class="queue-display" description="The set of characters used for mapping brightness levels."
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>
</div> </div>
<div class="panel-divider-h"></div> <div class="panel-divider-v"></div>
<div class="sub-section"> <!-- Right Column: Actions & Export -->
<div class="section-header">IMPORT / EXPORT</div> <div class="panel-section actions-section">
<div class="actions-grid"> <div class="sub-section">
<!-- Hidden File Input --> <div class="section-header">ACTIONS</div>
<input <div class="actions-grid">
type="file" <TuiButton
id="file-upload" id="btn-reset"
accept="image/*" label="RESET"
style="display: none;" shortcut="R"
/> title="Reset to Auto-detected Settings"
<TuiButton description="Resets all sliders and toggles to their default values."
id="btn-import" />
label="IMP"
title="Import Image" <TuiButton
description="Upload your own image from your device." id="btn-next"
/> label="NEXT"
<TuiButton shortcut="N"
id="btn-save-png" variant="primary"
label="PNG" title="Load Next Image"
title="Save as Image" description="Discards current image and loads a new one from the queue."
description="Download high-res PNG capture of the current view." />
/>
<TuiButton <div
id="btn-copy-text" class="queue-display"
label="TXT" data-tooltip-title="Buffered Images"
title="Save as Text" data-tooltip-desc="Number of images pre-loaded in background queue."
description="Download raw ASCII text file." >
/> <span class="queue-label">Q:</span>
<TuiButton <span id="val-queue" class="queue-value">0</span>
id="btn-copy-html" </div>
label="HTML" </div>
title="Save as HTML" </div>
description="Download colored HTML file."
/> <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>
</div> </div>
</div>
<!-- Keyboard shortcuts hint --> <!-- Keyboard shortcuts hint -->
<div class="shortcuts-hint"> <div class="shortcuts-hint">
<span><kbd>N</kbd> Next</span> <span><kbd>N</kbd> Next</span>
<span><kbd>R</kbd> Reset</span> <span><kbd>R</kbd> Reset</span>
<span><kbd>I</kbd> Invert</span> <span><kbd>I</kbd> Invert</span>
<span><kbd>C</kbd> Color</span> <span><kbd>C</kbd> Color</span>
<span><kbd>D</kbd> Dither</span> <span><kbd>D</kbd> Dither</span>
<span><kbd>E</kbd> Edges</span> <span><kbd>E</kbd> Edges</span>
<span><kbd>S</kbd> Charset</span> <span><kbd>S</kbd> Charset</span>
</div>
</div> </div>
</footer> </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> <style>
/* Main Container matching Sidebar visual style */ /* Main Container matching Sidebar visual style */
.control-panel { .control-panel {
@@ -384,10 +494,62 @@ import TuiButton from "./TuiButton.astro";
color: #fff; color: #fff;
} }
.mobile-controls-header {
display: none;
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 1200px) { @media (max-width: 1200px) {
.control-panel { .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; 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 { .control-panel-content {
@@ -408,4 +570,55 @@ import TuiButton from "./TuiButton.astro";
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); 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> </style>

View File

@@ -3,6 +3,22 @@
--- ---
<aside class="sidebar"> <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="sidebar-content">
<div class="brand-group"> <div class="brand-group">
<a href="/" class="brand-link"> <a href="/" class="brand-link">
@@ -10,24 +26,24 @@
</a> </a>
<div class="brand-subtitle"> <div class="brand-subtitle">
FULL STACK ENGINEER FULL STACK ENGINEER
<span class="muted">//</span> <span class="muted">|</span>
CREATIVE TECHNOLOGIST CREATIVE TECHNOLOGIST
</div> </div>
</div> </div>
<p class="brand-bio"> <p class="brand-bio">
Crafting high-performance digital experiences at the intersection of Crafting high-performance digital experiences at the intersection of
code, art, and artificial intelligence. engineering, design, and artificial intelligence.
</p> </p>
<div class="sidebar-actions"> <div class="sidebar-actions">
<a href="/projects" class="sidebar-link"> <a href="/" class="sidebar-link">
<span class="icon">⚡</span> PROJECTS <span class="icon">⚡</span> GENERATE
</a> </a>
<a href="/blog" class="sidebar-link"> <a href="/blog" class="sidebar-link">
<span class="icon">📝</span> BLOG <span class="icon">📝</span> BLOG
</a> </a>
<a href="mailto:contact@syntaxbullet.me" class="sidebar-link"> <a href="mailto:me@syntaxbullet.com" class="sidebar-link">
<span class="icon">✉️</span> CONTACT <span class="icon">✉️</span> CONTACT
</a> </a>
</div> </div>
@@ -116,6 +132,10 @@
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5); box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5);
} }
.mobile-header {
display: none;
}
.sidebar-content { .sidebar-content {
padding: 4rem 3rem; padding: 4rem 3rem;
display: flex; display: flex;
@@ -224,13 +244,52 @@
min-width: 0; min-width: 0;
border-right: none; border-right: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1); 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 { .sidebar-content {
padding: 1rem 3rem; /* Increased horizontal padding */ padding: 0 3rem 3rem 3rem; /* Adjust padding */
align-items: center; align-items: center;
text-align: 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, .brand-subtitle,
@@ -238,5 +297,29 @@
justify-content: center; justify-content: center;
align-items: 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> </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 { getEntry } from "astro:content";
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import Sidebar from "../../components/Sidebar.astro";
const { slug } = Astro.params; const { slug } = Astro.params;
if (!slug) { if (!slug) {
@@ -16,117 +17,243 @@ if (!entry) {
const { Content } = await entry.render(); const { Content } = await entry.render();
--- ---
<Layout title={entry.data.title} showScroll={true}> <Layout title={entry.data.title}>
<main> <div class="split-layout">
<article> <Sidebar />
<section class="h-entry">
<header> <main class="content-workspace">
<h1 class="p-name">{entry.data.title}</h1> <div class="content-container">
<div class="metadata"> <article class="h-entry">
<time <header class="post-header">
class="dt-published" <a href="/blog" class="back-link">
datetime={entry.data.pubDate.toISOString()} &larr; Back to Blog
> </a>
{entry.data.pubDate.toISOString().slice(0, 10)}
</time> <h1 class="p-name">{entry.data.title}</h1>
{
entry.data.updatedDate && ( <div class="metadata">
<div class="last-updated"> <span class="dt-published">
Last updated on{" "} {entry.data.pubDate.toISOString().slice(0, 10)}
<time> </span>
{
entry.data.updatedDate && (
<span class="updated-date">
&nbsp;&bull;&nbsp; Updated:{" "}
{entry.data.updatedDate {entry.data.updatedDate
.toISOString() .toISOString()
.slice(0, 10)} .slice(0, 10)}
</time> </span>
</div> )
) }
} </div>
<div class="divider"></div>
</header>
<div class="e-content">
<Content />
</div> </div>
</header> </article>
<div class="e-content"> </div>
<Content /> </main>
</div> </div>
</section>
</article>
</main>
</Layout> </Layout>
<style> <style>
main { /* Split Layout */
width: calc(100% - 2em); .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; max-width: 800px;
margin: 0; width: 100%;
padding: 2em; margin: 0 auto;
padding: 4rem 2rem;
box-sizing: border-box;
} }
header { /* Header Styling */
margin-bottom: 2rem; .post-header {
margin-bottom: 3rem;
} }
header a { .back-link {
display: inline-block; display: inline-block;
margin-bottom: 1rem; margin-bottom: 1.5rem;
color: var(--text-color); font-family:
opacity: 0.6; system-ui,
font-family: var(--font-mono); -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 { .back-link:hover {
opacity: 1; color: #fff;
} }
.title { h1 {
font-size: 2em; font-size: 2.5rem;
margin: 0.25em 0 0; font-weight: 800;
} margin: 0 0 1rem 0;
line-height: 1.2;
hr { color: #fff;
border-top: 1px solid var(--text-color); letter-spacing: -0.5px;
opacity: 0.3;
margin: 1rem 0;
} }
.metadata { .metadata {
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--text-color);
opacity: 0.8;
font-size: 0.9rem; 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 { .e-content {
line-height: 1.6; line-height: 1.8;
font-family: var(--font-mono); /* Keep vibe */ font-size: 1.1rem;
font-size: 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(h1),
.e-content :global(h2), .e-content :global(h2),
.e-content :global(h3), .e-content :global(h3),
.e-content :global(h4) { .e-content :global(h4) {
margin-top: 2rem; font-family:
margin-bottom: 1rem; system-ui,
color: var(--text-color); -apple-system,
font-weight: bold; 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) { .e-content :global(a) {
color: var(--text-color); color: #fff;
text-decoration: underline; 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) { .e-content :global(code) {
background: #111;
padding: 2px 5px;
border-radius: 2px;
font-family: var(--font-mono); 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; color: #fff;
} }
.e-content :global(pre) { .e-content :global(pre code) {
background: #111; background: none;
padding: 1rem; padding: 0;
border: 1px solid #333; color: inherit;
overflow-x: auto; 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> </style>

View File

@@ -1,5 +1,6 @@
--- ---
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import Sidebar from "../../components/Sidebar.astro";
import { getCollection, type CollectionEntry } from "astro:content"; import { getCollection, type CollectionEntry } from "astro:content";
const posts = (await getCollection("blog")).sort( const posts = (await getCollection("blog")).sort(
@@ -8,92 +9,195 @@ const posts = (await getCollection("blog")).sort(
); );
--- ---
<Layout title="System Logs" showScroll={true}> <Layout title="System Logs">
<main> <div class="split-layout">
<section> <Sidebar />
<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>
<footer> <main class="content-workspace">
<p>END OF STREAM</p> <div class="content-container">
</footer> <header class="page-header">
</main> <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> </Layout>
<style> <style>
main { /* Split Layout - Consistent with Homepage */
width: 960px; .split-layout {
max-width: calc(100% - 2em); display: flex;
margin: 0 auto; width: 100vw;
padding: 2em 0; 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; list-style-type: none;
padding: 0; padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
} }
li { .post-link {
margin-bottom: 1rem; display: flex;
border-left: 2px solid transparent; flex-direction: column; /* Mobile first */
transition: border-left-color 0.2s; gap: 0.5rem;
}
li:hover {
border-left-color: var(--text-color);
}
a {
display: block;
text-decoration: none; text-decoration: none;
padding: 5px 10px; padding: 1.5rem;
color: var(--text-color); border: 1px solid rgba(255, 255, 255, 0.1);
font-family: var(--font-mono); background: rgba(255, 255, 255, 0.02);
transition: all 0.2s ease;
border-radius: 4px;
} }
.date { .post-link:hover {
color: rgba(255, 103, 0, 0.6); border-color: rgba(255, 255, 255, 0.3);
margin-right: 1rem; 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 { .title {
font-weight: bold; font-size: 1.25rem;
margin-right: 1rem; font-weight: 600;
color: #fff;
} }
.desc { .desc {
color: rgba(255, 103, 0, 0.4); color: rgba(255, 255, 255, 0.7);
font-style: italic; font-family:
} system-ui,
-apple-system,
a:hover .title { sans-serif;
text-decoration: underline; font-size: 1rem;
line-height: 1.5;
} }
footer { footer {
margin-top: 4rem; margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
text-align: center; text-align: center;
opacity: 0.3; opacity: 0.5;
font-size: 0.8rem; 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> </style>

View File

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

View File

@@ -59,6 +59,12 @@ export class AsciiController {
showMagnifier: false showMagnifier: false
}; };
// Touch state
private lastTouchDist = 0;
private isDragging = false;
private lastTouchPos = { x: 0, y: 0 };
private resizeObserver: ResizeObserver | null = null;
// Callbacks // Callbacks
private onSettingsChange?: () => void; private onSettingsChange?: () => void;
@@ -77,6 +83,33 @@ export class AsciiController {
} }
this.startRenderLoop(); 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 { private getDefaultSettings(): AsciiSettings {
@@ -92,7 +125,14 @@ export class AsciiController {
edgeMode: 0, edgeMode: 0,
overlayStrength: 0.3, overlayStrength: 0.3,
resolution: 1.0, 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, overlayStrength: suggestions.overlayStrength ?? this.settings.overlayStrength,
charSet: validCharSet, charSet: validCharSet,
resolution: this.settings.resolution, 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?.(); this.onSettingsChange?.();
@@ -280,6 +327,9 @@ export class AsciiController {
let widthCols = Math.floor(availW / 6); let widthCols = Math.floor(availW / 6);
widthCols = Math.floor(widthCols * this.settings.resolution); 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)); widthCols = Math.max(10, Math.min(1000, widthCols));
const imgEl = await this.resolveImage(this.currentImgUrl); const imgEl = await this.resolveImage(this.currentImgUrl);
@@ -362,7 +412,9 @@ export class AsciiController {
this.canvas.style.width = `${finalW}px`; this.canvas.style.width = `${finalW}px`;
this.canvas.style.height = `${finalH}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.width = finalW * dpr;
this.canvas.height = finalH * dpr; this.canvas.height = finalH * dpr;
} }
@@ -375,7 +427,7 @@ export class AsciiController {
this.requestRender('all'); this.requestRender('all');
} }
// ============= Zoom ============= // ============= Zoom & Touch =============
handleWheel(e: WheelEvent): void { handleWheel(e: WheelEvent): void {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
@@ -401,13 +453,16 @@ export class AsciiController {
} }
handleMouseMove(e: MouseEvent): void { handleMouseMove(e: MouseEvent): void {
if ('ontouchstart' in window && (e as any).sourceCapabilities?.firesTouchEvents) return; // Ignore simulated mouse events
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width; const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height; const my = (e.clientY - rect.top) / rect.height;
this.zoomState.mousePos = { x: mx, y: my }; this.zoomState.mousePos = { x: mx, y: my };
const wasShowing = this.zoomState.showMagnifier; 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) { if (this.zoomState.showMagnifier || wasShowing) {
this.requestRender('uniforms'); 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 ============= // ============= Export =============
savePNG(): void { savePNG(): void {
@@ -499,6 +622,9 @@ export class AsciiController {
if (this.animFrameId !== null) { if (this.animFrameId !== null) {
cancelAnimationFrame(this.animFrameId); cancelAnimationFrame(this.animFrameId);
} }
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.renderer?.dispose(); this.renderer?.dispose();
this.renderer = null; this.renderer = null;
} }

View File

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

View File

@@ -62,6 +62,7 @@ export class UIBindings {
init(): void { init(): void {
this.setupSliders(); this.setupSliders();
this.setupToggles(); this.setupToggles();
this.setupColorInput();
this.setupSegments(); this.setupSegments();
this.setupButtons(); this.setupButtons();
this.setupKeyboard(); this.setupKeyboard();
@@ -81,7 +82,7 @@ export class UIBindings {
} }
// Cleanup Sliders // 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 => { sliderIds.forEach(id => {
const input = document.getElementById(id) as HTMLInputElement | null; const input = document.getElementById(id) as HTMLInputElement | null;
const handler = this.sliderHandlers.get(id); const handler = this.sliderHandlers.get(id);
@@ -162,7 +163,7 @@ export class UIBindings {
// ============= Sliders ============= // ============= Sliders =============
private setupSliders(): void { 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 => { sliderIds.forEach(id => {
const input = document.getElementById(id) as HTMLInputElement | null; const input = document.getElementById(id) as HTMLInputElement | null;
@@ -225,6 +226,23 @@ export class UIBindings {
document.body.addEventListener('toggle-change', this.toggleHandler); 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 ============= // ============= Segments =============
private setupSegments(): void { private setupSegments(): void {
@@ -475,7 +493,7 @@ export class UIBindings {
const settings = this.controller.getSettings(); const settings = this.controller.getSettings();
// Update sliders // 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 => { sliderIds.forEach(id => {
const input = document.getElementById(id) as HTMLInputElement | null; const input = document.getElementById(id) as HTMLInputElement | null;
if (input && settings[id] !== undefined) { if (input && settings[id] !== undefined) {
@@ -509,6 +527,19 @@ export class UIBindings {
} }
window.updateSegmentValue?.('segment-edge', edgeShort); 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.updateQueueDisplay();
this.isUpdatingUI = false; this.isUpdatingUI = false;

View File

@@ -15,6 +15,13 @@ export interface RenderOptions {
edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny
dither?: number; dither?: number;
denoise?: boolean; denoise?: boolean;
sharpen?: number;
edgeThreshold?: number;
shadows?: number;
highlights?: number;
scanlines?: number;
vignette?: number;
monoColor?: string;
zoom?: number; zoom?: number;
zoomCenter?: { x: number; y: number }; zoomCenter?: { x: number; y: number };
mousePos?: { x: number; y: number }; mousePos?: { x: number; y: number };
@@ -52,6 +59,10 @@ export class WebGLAsciiRenderer {
} }
this.gl = gl; this.gl = gl;
// Enable required extensions for advanced rendering
gl.getExtension('OES_standard_derivatives');
gl.getExtension('EXT_shader_texture_lod');
this.program = null; this.program = null;
this.textures = {}; this.textures = {};
this.buffers = {}; this.buffers = {};
@@ -80,6 +91,8 @@ export class WebGLAsciiRenderer {
// Fragment Shader // Fragment Shader
const fsSource = ` const fsSource = `
#extension GL_OES_standard_derivatives : enable
#extension GL_EXT_shader_texture_lod : enable
precision mediump float; precision mediump float;
varying vec2 v_texCoord; varying vec2 v_texCoord;
@@ -102,6 +115,13 @@ export class WebGLAsciiRenderer {
uniform int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny uniform int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny
uniform float u_dither; // Dither strength 0.0 - 1.0 uniform float u_dither; // Dither strength 0.0 - 1.0
uniform bool u_denoise; 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 // Zoom & Magnifier
uniform float u_zoom; uniform float u_zoom;
@@ -125,11 +145,26 @@ export class WebGLAsciiRenderer {
// Exposure // Exposure
color *= u_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 // Contrast
color = (color - 0.5) * u_contrast + 0.5; color = (color - 0.5) * u_contrast + 0.5;
// Saturation // 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); color = mix(vec3(luma), color, u_saturation);
// Gamma // Gamma
@@ -231,6 +266,12 @@ export class WebGLAsciiRenderer {
} else { } else {
color = getAverageColor(sampleUV, cellSize); 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 // Edge Detection Logic
if (u_edgeMode == 1) { if (u_edgeMode == 1) {
@@ -244,14 +285,20 @@ export class WebGLAsciiRenderer {
vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right); 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)); 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) { } else if (u_edgeMode == 2) {
// Sobel Gradient // Sobel Gradient
vec2 sobel = sobelFilter(sampleUV, cellSize); vec2 sobel = sobelFilter(sampleUV, cellSize);
float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0); 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) { } else if (u_edgeMode == 3) {
// "Canny-like" (Sobel + gradient suppression) // "Canny-like" (Sobel + gradient suppression)
@@ -260,13 +307,12 @@ export class WebGLAsciiRenderer {
float angle = sobel.y; float angle = sobel.y;
// Non-maximum suppression (simplified) // Non-maximum suppression (simplified)
// Check neighbors in gradient direction
vec2 dir = vec2(cos(angle), sin(angle)) * cellSize; vec2 dir = vec2(cos(angle), sin(angle)) * cellSize;
vec2 s1 = sobelFilter(sampleUV + dir, cellSize); vec2 s1 = sobelFilter(sampleUV + dir, cellSize);
vec2 s2 = 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; mag = 0.0;
} else { } else {
mag = 1.0; // Strong edge mag = 1.0; // Strong edge
@@ -276,7 +322,7 @@ export class WebGLAsciiRenderer {
color = mix(color, vec3(0.0), mag); color = mix(color, vec3(0.0), mag);
} }
// Apply adjustments // Apply adjustments (Contrast, etc.)
color = adjust(color); color = adjust(color);
// Overlay blend-like effect (boost mid-contrast) // Overlay blend-like effect (boost mid-contrast)
@@ -319,18 +365,48 @@ export class WebGLAsciiRenderer {
uvInCell.y * u_charSizeUV.y 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 // Loup border effect
if (u_showMagnifier) { if (u_showMagnifier) {
float edgeWidth = 0.005; float edgeWidth = 0.005;
if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) { if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) {
charAlpha = 1.0; 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); gl_FragColor = vec4(finalColor * charAlpha, charAlpha);
} }
@@ -366,6 +442,8 @@ export class WebGLAsciiRenderer {
'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma', 'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma',
'u_invert', 'u_color', 'u_overlayStrength', 'u_edgeMode', 'u_invert', 'u_color', 'u_overlayStrength', 'u_edgeMode',
'u_dither', 'u_denoise', '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_zoom', 'u_zoomCenter', 'u_mousePos',
'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect' 'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect'
]; ];
@@ -518,6 +596,19 @@ export class WebGLAsciiRenderer {
gl.uniform1i(u['u_edgeMode'], options.edgeMode || 0); gl.uniform1i(u['u_edgeMode'], options.edgeMode || 0);
gl.uniform1f(u['u_dither'], options.dither || 0.0); gl.uniform1f(u['u_dither'], options.dither || 0.0);
gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 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 // Zoom & Magnifier
gl.uniform1f(u['u_zoom'], options.zoom || 1.0); gl.uniform1f(u['u_zoom'], options.zoom || 1.0);

View File

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