Skip to content

feat: improve dithering visual quality and add gradient backgrounds - #9

Merged
rowkav09 merged 2 commits into
mainfrom
feat/improve-dithering-and-backgrounds-17382717613092662407
Aug 8, 2026
Merged

feat: improve dithering visual quality and add gradient backgrounds#9
rowkav09 merged 2 commits into
mainfrom
feat/improve-dithering-and-backgrounds-17382717613092662407

Conversation

@rowkav09

@rowkav09 rowkav09 commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary of Dithering Issues & Root Causes Found

  1. Continuous Blending Washed Out Patterns: The old implementation blended the original continuous tone back into the quantized/dithered result (e.g. source * (1 - strength) + dithered * strength). This reintroduced continuous non-quantized values into the tone field.
  2. Double Quantisation in Glyph Mapping: The glyph mapping function glyphForTone was non-linear:
    const index = Math.round(Math.pow(clamp(tone), 0.72) * Math.max(0, glyphs.length - 1));
    Applying a power function of 0.72 after linear dithering distorted the dithered levels, causing adjacent levels to collapse to the same glyph or leaving unused gaps, completely mangling the dither pattern.
  3. No True Blue Noise: The old blue noise implementation was a cheap sine-based pseudo-random hash rather than a true dither matrix, resulting in high-frequency white-noise-like clumping.

Solutions & Renderer Changes

  1. Perfect Quantisation & Dither Strength:
    • Redesigned ordered (Bayer) and error diffusion dithering to output fully quantized levels.
    • Scaled dither strength linearly: for Ordered/Bayer dither, we scale the threshold (source + (threshold * strength) / levels), and for Error Diffusion dither, we scale the distributed error ((original - mapped) * strength), ensuring 0% strength is identical to no dithering (pure quantization).
  2. Linear Glyph Mapping:
    • Modified glyphForTone to be linear: Math.round(clamp(tone) * Math.max(0, glyphs.length - 1)). This ensures dithered dots map exactly to their intended glyph indices and fully survive.
  3. High-Quality Blue Noise:
    • Implemented a deterministic 16x16 Blue Noise threshold matrix generated via Mitchell's Best Candidate algorithm.
  4. Gradient & Transparent Backgrounds:
    • Added support for Solid, Linear gradient, Radial gradient, and Transparent backgrounds in the renderer, canvas preview, and SVG/HTML/PNG exports.
    • Added preset selectors for gradient angles (Horizontal, Vertical, Diagonal ↘, Diagonal ↗).
  5. Colour Treatments:
    • Added optional Duotone (luminance-based interpolation), Gradient Map (2-4 colors mapped across luminance), and Palette (direct index mapping) color treatments.
  6. Advanced Controls & Tooling:
    • Expose aspect ratio, fitting/cropping focus controls, pre-dither noise/grain, and posterise levels separate from glyph counts.
    • Added grouped select menus for dithering with descriptions, and a live "Dither Compare" modal to view 4 algorithms side-by-side.

Tests Added

  • Comprehensive test suite in test/renderer/dithering.test.ts verifying determinism, 1x1 edge cases, strength = 0, 0-1 tone range bounds, and distinct patterns on gradients for all ordered/diffusion ditherers.

Performance Impact

  • Generating the blue noise matrix is done once at load-time (in milliseconds). Rendering remains extremely fast and memory-efficient.

Summary by CodeRabbit

  • New Features
    • Added image adjustment controls for aspect ratio, fit/crop positioning, grain, and posterisation.
    • Added color treatments including monochrome, duotone, gradient-map, and palette effects.
    • Added transparent, solid, linear-gradient, and radial-gradient backgrounds.
    • Added a dither comparison view for previewing multiple rendering algorithms.
  • Enhancements
    • Presets and shared image links now preserve expanded adjustment, color, and background settings.
    • HTML and SVG exports now match the selected background configuration.

@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
halftone Ready Ready Preview Aug 8, 2026 8:15am

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@rowkav09, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5301d44b-8740-433b-9d3a-8ec85a1e9c11

📥 Commits

Reviewing files that changed from the base of the PR and between 841b685 and de937bf.

📒 Files selected for processing (2)
  • src/lib/art.ts
  • src/lib/artExport.ts
📝 Walkthrough

Walkthrough

The renderer gains configurable image fitting, posterisation, grain, color treatments, and backgrounds. HTML, SVG, and canvas output use background configurations. The editor adds presets, controls, URL-default preservation, and a four-algorithm dither comparison modal.

Changes

Rendering configuration and output

Layer / File(s) Summary
Rendering configuration contracts
src/lib/renderer/types.ts, src/lib/art.ts
Image adjustments now include aspect ratio, fit and crop settings, grain, seed, and posterisation. Background and color-treatment configurations now have typed defaults.
Image sampling and color processing
src/lib/art.ts
Image generation supports stretch, contain, and crop-aware cover fitting. It applies seeded grain, configurable posterisation, and source, monochrome, duotone, gradient-map, or palette color treatments.
Canvas and export backgrounds
src/lib/art.ts, src/lib/artExport.ts, test/helpers/canvas.ts
Canvas, HTML, and SVG rendering support transparent, solid, linear-gradient, and radial-gradient backgrounds. Canvas test mocks support gradient APIs.
Editor presets and dither comparison
src/app/page.tsx
The editor applies extended presets, preserves default adjustment fields during URL hydration, exposes new image controls, and compares four dithering algorithms in a modal.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested labels: feature

Sequence Diagram(s)

sequenceDiagram
  participant Editor
  participant DitherCompareModal
  participant Renderer
  participant Canvas
  Editor->>DitherCompareModal: Open comparison with current image and options
  DitherCompareModal->>Renderer: Generate one result per dithering algorithm
  Renderer-->>DitherCompareModal: Return generated art
  DitherCompareModal->>Canvas: Draw labeled comparison canvases
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: improved dithering quality and added gradient background support.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/page.tsx`:
- Around line 601-605: Update the preset-application logic in page.tsx to assign
default values for backgroundConfig and colorTreatmentConfig when those
properties are omitted, instead of retaining prior preset state. Apply the same
default-reset behavior in resetImageSettings, reusing the existing default
configuration symbols.
- Line 658: Update the share-URL serialization and hydration logic in the page
component to include the new image adjustment fields fitMode, cropX, cropY,
aspectRatio, posteriseLevels, and grainAmount, preserving their values after
shared-link reloads. Also serialize and restore background and color-treatment
configuration when preset state is intended to be shareable, using the existing
adjustment/default symbols and URL effect paths.
- Around line 365-370: Update the image-loading flow around the image.onload
handler to ignore results whose URL no longer matches fileUrlRef.current,
checking this before updating imageRef, loadedImage, image readiness, or status.
When starting a new image load, clear the previously loaded image so stale
previews are not retained.
- Around line 666-682: Update the primary rendering flow around renderArt and
drawGeneratedArt to pass backgroundConfig and colorTreatmentConfig in the
ArtOptions, matching the options supplied to DitherCompareModal. Replace the
generated.background-only canvas fill in drawGeneratedArt with
drawBackgroundOnCanvas so the preview and PNG export apply the configured
background and color treatment.
- Around line 240-265: Add a render-generation token to the comparison-rendering
effect so each [image, options] change invalidates prior asynchronous jobs.
Capture the token before starting each generateArtFromImage call, then verify it
is still current before updating canvas dimensions or drawing generated output;
ignore stale results while preserving current rendering and error handling.

In `@src/lib/art.ts`:
- Around line 572-576: Update the gradient construction near interpolateColors
in art.ts to compute the midpoint color using a fixed 50% blend between
gradientStart and gradientEnd, while keeping gradientMidpoint as the third stop
position. Apply the same midpoint-stop behavior to the corresponding HTML and
SVG gradient generation in artExport.ts.
- Around line 551-553: Update the background handling around the config check so
an absent config fills the canvas with fallbackBackground, while config.type ===
"transparent" continues to clear it and return. Keep the existing dimensions and
rendering flow unchanged.
- Around line 73-74: Update renderArt to pass backgroundConfig and
colorTreatmentConfig into the ArtOptions supplied to generateArtFromImage, and
include both values in the callback dependency list. In drawGeneratedArt,
populate generated.backgroundConfig with the saved configuration values before
invoking drawBackgroundOnCanvas, preserving the existing generated.background
handling.

In `@src/lib/artExport.ts`:
- Around line 59-60: Update both linear-gradient exporters in
src/lib/artExport.ts at lines 59-60 and 113-121: use config.gradientMidpoint in
the CSS output via appropriate color stops or hint, and add a corresponding
interpolated SVG stop so HTML and SVG render the same midpoint transition; both
sites require changes.
- Line 72: Update Home.renderArt to pass the selected backgroundConfig and
colorTreatmentConfig values when constructing ArtOptions, and include both
values in the renderArt callback dependency list. Preserve the existing
getBackgroundCss flow so exported art uses the selected transparent or gradient
background configuration.
- Around line 78-85: Correct the angle conversion in angleToCoordinates so CSS
0deg produces a bottom-to-top SVG gradient and all CSS angles align without the
current 90-degree rotation; adjust the trigonometric angle mapping while
preserving the existing coordinate return shape and rounding.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a824e5c-dde2-4777-9a25-d222f96ad75d

📥 Commits

Reviewing files that changed from the base of the PR and between 4a5af2a and 841b685.

⛔ Files ignored due to path filters (2)
  • dev_server.log is excluded by !**/*.log
  • package-lock.json is excluded by !**/package-lock.json, !package-lock.json
📒 Files selected for processing (5)
  • src/app/page.tsx
  • src/lib/art.ts
  • src/lib/artExport.ts
  • src/lib/renderer/types.ts
  • test/helpers/canvas.ts

Comment thread src/app/page.tsx
Comment on lines +240 to +265
useEffect(() => {
if (!image) return;
COMPARE_ALGS.forEach(async (alg, idx) => {
const canvas = canvasRefs[idx]?.current;
if (!canvas) return;
try {
const algOptions = { ...options, ditherAlgorithm: alg };
const generated = await generateArtFromImage(image, algOptions);
const context = canvas.getContext("2d");
if (!context) return;
const metrics = canvasMetrics(generated);
canvas.width = metrics.width;
canvas.height = metrics.height;
drawBackgroundOnCanvas(context, canvas.width, canvas.height, generated.backgroundConfig, generated.background);
context.font = `${metrics.fontSize}px var(--font-mono), ui-monospace, monospace`;
context.textBaseline = "top";
generated.lines.forEach((line, row) => Array.from(line).forEach((glyph, column) => {
context.fillStyle = generated.colors[row]?.[column] ?? generated.foreground;
context.fillText(glyph, metrics.padding + column * metrics.glyphAdvance, metrics.padding + row * metrics.lineHeight);
}));
} catch (err) {
console.error("Comparison render error", err);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [image, options]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg '(^|/)src/app/page\.tsx$|(^|/)package\.json$|(^|/)tsconfig\.json$' || true

echo
echo "Section around comparison effect:"
sed -n '200,290p' src/app/page.tsx | cat -n -v

echo
echo "Imports and state/options declarations:"
sed -n '1,140p' src/app/page.tsx | cat -n -v

echo
echo "Remaining relevant references:"
rg -n "COMPARE_ALGS|generateArtFromImage|useEffect|options|generated" src/app/page.tsx

Repository: rowkav09/halftone

Length of output: 14807


🌐 Web query:

React async functions in useEffect cleanup stale closure return after cleanup useEffect documentation

💡 Result:

React does not allow useEffect to be an async function [1][2][3]. The useEffect hook expects its callback to return either nothing (undefined) or a cleanup function [4][5][1]. Because async functions inherently return a Promise, passing one to useEffect violates this contract [4][6][2]. If you pass an async function to useEffect, React will not treat the returned Promise as a cleanup function; it simply ignores it or, in many environments, logs a warning [4]. This leads to two primary issues: 1. Stale Closures and Race Conditions: If you perform asynchronous operations (like API calls) directly, multiple executions of the effect can lead to race conditions where older requests complete after newer ones, causing incorrect state updates [1][3]. 2. Memory Leaks: Without a cleanup function to cancel ongoing tasks (such as by using an AbortController or a boolean flag), your component may attempt to update state after it has unmounted, leading to memory leaks and errors [7][1][3]. To handle async code safely within useEffect, follow these best practices: Define an async function inside the useEffect hook, then call it immediately (or use an IIFE) [6][1][2]. This keeps the useEffect callback itself synchronous and compliant with React's expectations [1][3]. Use a cleanup mechanism, such as an AbortController or a simple boolean flag, to ignore or cancel the result of the async operation if the component unmounts or the effect re-runs [7][1][3]. Example of the correct pattern: useEffect( => { let ignore = false; async function fetchData { const data = await fetchFromApi; if (!ignore) { setData(data); } } fetchData; return => { ignore = true; // Prevents state update on unmounted component }; }, [dependency]); By returning this cleanup function, you ensure that even if the component unmounts or dependencies change, the logic properly cancels stale effects and prevents unwanted side effects [8][7][1].

Citations:


Use a render token for comparison outputs.

Each image/options change starts four async generateArtFromImage calls inside the effect. If an earlier job finishes after a later job, it draws stale output to the comparison canvases. Store a render generation token and ignore generated.lines/canvas writes when the token changes, or pass a cancel signal into the generator.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/page.tsx` around lines 240 - 265, Add a render-generation token to
the comparison-rendering effect so each [image, options] change invalidates
prior asynchronous jobs. Capture the token before starting each
generateArtFromImage call, then verify it is still current before updating
canvas dimensions or drawing generated output; ignore stale results while
preserving current rendering and error handling.

Comment thread src/app/page.tsx
Comment on lines +365 to +370
image.onload = () => {
imageRef.current = image;
setLoadedImage(image);
setImageReady(true);
setStatus("Image loaded. Tune the live renderer below.");
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ignore load events from replaced uploads.

If a user selects file B before file A finishes, file A can complete last and replace imageRef, loadedImage, and the preview with the wrong image. Check that fileUrlRef.current === url before committing either load result. Clear the previous loaded image when a new load starts.

Proposed fix
 image.onload = () => {
+  if (fileUrlRef.current !== url) return;
   imageRef.current = image;
   setLoadedImage(image);
   setImageReady(true);
   setStatus("Image loaded. Tune the live renderer below.");
 };
-image.onerror = () => { setStatus("That file could not be loaded as an image."); setImageReady(false); };
+image.onerror = () => {
+  if (fileUrlRef.current !== url) return;
+  setStatus("That file could not be loaded as an image.");
+  setImageReady(false);
+};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/page.tsx` around lines 365 - 370, Update the image-loading flow
around the image.onload handler to ignore results whose URL no longer matches
fileUrlRef.current, checking this before updating imageRef, loadedImage, image
readiness, or status. When starting a new image load, clear the previously
loaded image so stale previews are not retained.

Comment thread src/app/page.tsx
Comment on lines +601 to +605
if (preset.palette) setPalette(preset.palette);
if (preset.colorMode) setColorMode(preset.colorMode);
if (preset.colorCount !== undefined) setColorCount(preset.colorCount);
if (preset.backgroundConfig) setBackgroundConfig(preset.backgroundConfig);
if (preset.colorTreatmentConfig) setColorTreatmentConfig(preset.colorTreatmentConfig);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reset omitted preset configurations to defaults.

A preset without backgroundConfig or colorTreatmentConfig retains values from the previous preset. For example, applying Clean ASCII after Cyberpunk leaves the Cyberpunk treatment in the dither comparison. Set each state value to its default when the preset omits it. Reset these values in resetImageSettings too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/page.tsx` around lines 601 - 605, Update the preset-application logic
in page.tsx to assign default values for backgroundConfig and
colorTreatmentConfig when those properties are omitted, instead of retaining
prior preset state. Apply the same default-reset behavior in resetImageSettings,
reusing the existing default configuration symbols.

Comment thread src/app/page.tsx
<div className="space-y-3 rounded-md border border-white/10 bg-black/40 p-3"><div className="flex items-center justify-between"><h2 className="text-[10px] uppercase tracking-[0.3em] text-slate-500">Renderer</h2><button type="button" title="Copy this configuration from the address bar" onClick={() => { void navigator.clipboard.writeText(window.location.href); setStatus("Shareable settings URL copied."); }} className="text-[10px] uppercase tracking-[0.18em] text-emerald-200 transition hover:text-emerald-100">Copy link</button></div><label className="block text-[10px] uppercase tracking-[0.2em] text-slate-500">Dithering<select value={ditherAlgorithm} onChange={(event) => setDitherAlgorithm(event.target.value as DitherAlgorithm)} className="mt-2 w-full rounded-sm border border-white/10 bg-black px-3 py-2 text-sm normal-case tracking-normal text-white outline-none focus:border-emerald-300/40">{DITHER_ALGORITHMS.map((algorithm) => <option key={algorithm} value={algorithm}>{DITHER_LABELS[algorithm]}</option>)}</select></label><label className="block text-[10px] uppercase tracking-[0.2em] text-slate-500">Render mode<select value={renderMode} onChange={(event) => setRenderMode(event.target.value as RenderMode)} className="mt-2 w-full rounded-sm border border-white/10 bg-black px-3 py-2 text-sm normal-case tracking-normal text-white outline-none focus:border-emerald-300/40">{RENDER_MODES.map((renderOption) => <option key={renderOption} value={renderOption}>{RENDER_LABELS[renderOption]}</option>)}</select></label><div className="flex items-center justify-between border-t border-white/[0.07] pt-3"><div><h3 className="text-[10px] uppercase tracking-[0.2em] text-slate-500">Invert</h3><p className="mt-1 text-xs text-slate-500">Dark/light swap</p></div><button type="button" onClick={() => setInvert((value) => !value)} className={`h-7 w-12 rounded-sm border transition ${invert ? "border-emerald-300/40 bg-emerald-300/20" : "border-white/10 bg-white/10"}`} aria-label="Invert output" aria-pressed={invert}><span className={`block h-4 w-4 rounded-sm bg-white transition ${invert ? "translate-x-6" : "translate-x-1"}`} /></button></div></div>

{mode === "image" ? <div className="space-y-2 rounded-md border border-white/10 bg-black/40 p-3"><h2 className="text-[10px] uppercase tracking-[0.3em] text-slate-500">Image controls</h2><RangeControl label="Brightness" value={adjustments.brightness} min={-100} max={100} step={1} onChange={(value) => updateAdjustment("brightness", value)} format={(value) => `${value > 0 ? "+" : ""}${value}`} /><RangeControl label="Contrast" value={adjustments.contrast} min={-100} max={100} step={1} onChange={(value) => updateAdjustment("contrast", value)} format={(value) => `${value > 0 ? "+" : ""}${value}`} /><RangeControl label="Gamma" value={adjustments.gamma} min={0.4} max={2.5} step={0.05} onChange={(value) => updateAdjustment("gamma", value)} format={(value) => value.toFixed(2)} /><RangeControl label="Saturation" value={adjustments.saturation} min={0} max={2} step={0.05} onChange={(value) => updateAdjustment("saturation", value)} format={(value) => `${Math.round(value * 100)}%`} /><RangeControl label="Threshold" value={adjustments.threshold} min={0} max={0.95} step={0.05} onChange={(value) => updateAdjustment("threshold", value)} format={(value) => value === 0 ? "Off" : `${Math.round(value * 100)}%`} /><RangeControl label="Dither strength" value={adjustments.ditherStrength} min={0} max={1} step={0.05} onChange={(value) => updateAdjustment("ditherStrength", value)} format={(value) => `${Math.round(value * 100)}%`} /><RangeControl label="Pre-filter" value={adjustments.preBlur} min={0} max={0.75} step={0.05} onChange={(value) => updateAdjustment("preBlur", value)} format={(value) => value === 0 ? "Off" : `${Math.round(value * 100)}%`} /><RangeControl label="Sharpness" value={adjustments.sharpness} min={0} max={100} step={1} onChange={(value) => updateAdjustment("sharpness", value)} suffix="%" /><RangeControl label="Blur" value={adjustments.blur} min={0} max={4} step={0.25} onChange={(value) => updateAdjustment("blur", value)} format={(value) => value === 0 ? "Off" : value.toFixed(2)} /></div> : null}
{mode === "image" ? <div className="space-y-2 rounded-md border border-white/10 bg-black/40 p-3"><h2 className="text-[10px] uppercase tracking-[0.3em] text-slate-500">Image controls</h2><RangeControl label="Brightness" value={adjustments.brightness} min={-100} max={100} step={1} onChange={(value) => updateAdjustment("brightness", value)} format={(value) => `${value > 0 ? "+" : ""}${value}`} /><RangeControl label="Contrast" value={adjustments.contrast} min={-100} max={100} step={1} onChange={(value) => updateAdjustment("contrast", value)} format={(value) => `${value > 0 ? "+" : ""}${value}`} /><RangeControl label="Gamma" value={adjustments.gamma} min={0.4} max={2.5} step={0.05} onChange={(value) => updateAdjustment("gamma", value)} format={(value) => value.toFixed(2)} /><RangeControl label="Saturation" value={adjustments.saturation} min={0} max={2} step={0.05} onChange={(value) => updateAdjustment("saturation", value)} format={(value) => `${Math.round(value * 100)}%`} /><RangeControl label="Threshold" value={adjustments.threshold} min={0} max={0.95} step={0.05} onChange={(value) => updateAdjustment("threshold", value)} format={(value) => value === 0 ? "Off" : `${Math.round(value * 100)}%`} /><RangeControl label="Dither strength" value={adjustments.ditherStrength} min={0} max={1} step={0.05} onChange={(value) => updateAdjustment("ditherStrength", value)} format={(value) => `${Math.round(value * 100)}%`} /><RangeControl label="Pre-filter" value={adjustments.preBlur} min={0} max={0.75} step={0.05} onChange={(value) => updateAdjustment("preBlur", value)} format={(value) => value === 0 ? "Off" : `${Math.round(value * 100)}%`} /><RangeControl label="Sharpness" value={adjustments.sharpness} min={0} max={100} step={1} onChange={(value) => updateAdjustment("sharpness", value)} suffix="%" /><RangeControl label="Blur" value={adjustments.blur} min={0} max={4} step={0.25} onChange={(value) => updateAdjustment("blur", value)} format={(value) => value === 0 ? "Off" : value.toFixed(2)} /><label className="block text-[10px] uppercase tracking-[0.2em] text-slate-500 mt-2">Fit Mode<select value={adjustments.fitMode} onChange={(e) => updateAdjustment("fitMode", e.target.value as any)} className="mt-2 w-full rounded-sm border border-white/10 bg-black px-3 py-2 text-sm text-white outline-none focus:border-emerald-300/40"><option value="stretch">Stretch</option><option value="contain">Contain</option><option value="cover">Cover</option></select></label>{adjustments.fitMode === "cover" && ( <div className="space-y-2 mt-2"><RangeControl label="Crop X" value={adjustments.cropX} min={0} max={100} step={5} suffix="%" onChange={(value) => updateAdjustment("cropX", value)} /><RangeControl label="Crop Y" value={adjustments.cropY} min={0} max={100} step={5} suffix="%" onChange={(value) => updateAdjustment("cropY", value)} /></div> )}<RangeControl label="Character Aspect" value={adjustments.aspectRatio} min={0.2} max={1.5} step={0.05} onChange={(value) => updateAdjustment("aspectRatio", value)} format={(value) => value === 1.0 ? "Square (1.0)" : value === 0.6 ? "Standard (0.6)" : value.toFixed(2)} /><RangeControl label="Posterise Levels" value={adjustments.posteriseLevels} min={0} max={32} step={1} onChange={(value) => updateAdjustment("posteriseLevels", value)} format={(value) => value === 0 ? "Off (Auto)" : `${value} levels`} /><RangeControl label="Grain Amount" value={adjustments.grainAmount} min={0} max={1} step={0.05} onChange={(value) => updateAdjustment("grainAmount", value)} format={(value) => value === 0 ? "Off" : `${Math.round(value * 100)}%`} /></div> : null}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Persist the new image controls in the share URL.

The URL effect writes only the legacy adjustment fields. fitMode, cropX, cropY, aspectRatio, posteriseLevels, and grainAmount reset to defaults after a shared link reloads. Serialize and hydrate these fields. Include background and color-treatment configuration if presets are intended to be shareable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/page.tsx` at line 658, Update the share-URL serialization and
hydration logic in the page component to include the new image adjustment fields
fitMode, cropX, cropY, aspectRatio, posteriseLevels, and grainAmount, preserving
their values after shared-link reloads. Also serialize and restore background
and color-treatment configuration when preset state is intended to be shareable,
using the existing adjustment/default symbols and URL effect paths.

Comment thread src/app/page.tsx
Comment on lines +666 to +682
{showDitherCompare && loadedImage && (
<DitherCompareModal
image={loadedImage}
options={{
columns: resolutionColumns,
characterSet,
customText: customGlyphs,
invert,
palette,
colorMode,
colorCount,
renderMode,
adjustments,
backgroundSeparation,
backgroundConfig,
colorTreatmentConfig,
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Apply background and color treatment to the primary renderer.

These configurations reach only DitherCompareModal. renderArt constructs ArtOptions without backgroundConfig and colorTreatmentConfig, so the main preview does not use preset configuration. The PNG export also captures that incorrect preview. Pass both fields to renderArt, and use drawBackgroundOnCanvas in drawGeneratedArt instead of filling only generated.background.

Proposed fix
-const options: ArtOptions = { columns: resolutionColumns, characterSet, customText: customGlyphs, invert, palette, colorMode, colorCount, ditherAlgorithm, renderMode, adjustments, backgroundSeparation };
+const options: ArtOptions = {
+  columns: resolutionColumns,
+  characterSet,
+  customText: customGlyphs,
+  invert,
+  palette,
+  colorMode,
+  colorCount,
+  ditherAlgorithm,
+  renderMode,
+  adjustments,
+  backgroundSeparation,
+  backgroundConfig,
+  colorTreatmentConfig,
+};

-context.fillStyle = generated.background;
-context.fillRect(0, 0, canvas.width, canvas.height);
+drawBackgroundOnCanvas(
+  context,
+  canvas.width,
+  canvas.height,
+  generated.backgroundConfig,
+  generated.background
+);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/page.tsx` around lines 666 - 682, Update the primary rendering flow
around renderArt and drawGeneratedArt to pass backgroundConfig and
colorTreatmentConfig in the ArtOptions, matching the options supplied to
DitherCompareModal. Replace the generated.background-only canvas fill in
drawGeneratedArt with drawBackgroundOnCanvas so the preview and PNG export apply
the configured background and color treatment.

Comment thread src/lib/art.ts Outdated
Comment thread src/lib/art.ts
Comment thread src/lib/artExport.ts Outdated
Comment thread src/lib/artExport.ts
const fallback = safeColour(art.foreground, "#e8edf2");
const rows = art.lines.map((line, row) => colouredHtmlLine(line, art.colors[row] ?? [], fallback)).join("\n");
const background = options.background === null ? "" : `background:${safeColour(options.background ?? art.background, "#000000")};`;
const backgroundStyle = getBackgroundCss(art.backgroundConfig, art.background);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pass the selected render configuration into ArtOptions.

The supplied Home.renderArt implementation omits backgroundConfig and colorTreatmentConfig when it creates ArtOptions. GeneratedArt.backgroundConfig then uses the renderer default. Line 72 cannot export the selected transparent or gradient background.

Add both values to ArtOptions and to the renderArt callback dependencies.

Proposed fix
- const options: ArtOptions = { columns: resolutionColumns, characterSet, customText: customGlyphs, invert, palette, colorMode, colorCount, ditherAlgorithm, renderMode, adjustments, backgroundSeparation };
+ const options: ArtOptions = {
+   columns: resolutionColumns,
+   characterSet,
+   customText: customGlyphs,
+   invert,
+   palette,
+   colorMode,
+   colorCount,
+   ditherAlgorithm,
+   renderMode,
+   adjustments,
+   backgroundSeparation,
+   backgroundConfig,
+   colorTreatmentConfig,
+ };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/artExport.ts` at line 72, Update Home.renderArt to pass the selected
backgroundConfig and colorTreatmentConfig values when constructing ArtOptions,
and include both values in the renderArt callback dependency list. Preserve the
existing getBackgroundCss flow so exported art uses the selected transparent or
gradient background configuration.

Comment thread src/lib/artExport.ts
Repository owner deleted a comment from google-labs-jules Bot Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant