feat: improve dithering visual quality and add gradient backgrounds - #9
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
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 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThe 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. ChangesRendering configuration and output
Estimated code review effort: 4 (Complex) | ~60 minutes Suggested labels: 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
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
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. Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (2)
dev_server.logis excluded by!**/*.logpackage-lock.jsonis excluded by!**/package-lock.json,!package-lock.json
📒 Files selected for processing (5)
src/app/page.tsxsrc/lib/art.tssrc/lib/artExport.tssrc/lib/renderer/types.tstest/helpers/canvas.ts
| 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]); |
There was a problem hiding this comment.
🎯 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.tsxRepository: 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:
- 1: https://react.wiki/hooks/fetching-api-best-practice/
- 2: https://dev.to/vyan/using-async-functions-in-useeffect-best-practices-and-pitfalls-o75
- 3: https://webeyez.com/insights/guides/how-to-write-async-function-in-useeffect
- 4: https://stackoverflow.com/questions/72957956/are-async-useeffect-callbacks-actually-harmful-or-just-a-smell
- 5: https://stackoverflow.com/questions/53332321/react-hook-warnings-for-async-function-in-useeffect-useeffect-function-must-ret
- 6: https://codingbeautydev.com/blog/react-useeffect-async/
- 7: https://overreacted.io/a-complete-guide-to-useeffect/
- 8: https://react.dev/learn/synchronizing-with-effects
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.
| image.onload = () => { | ||
| imageRef.current = image; | ||
| setLoadedImage(image); | ||
| setImageReady(true); | ||
| setStatus("Image loaded. Tune the live renderer below."); | ||
| }; |
There was a problem hiding this comment.
🎯 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.
| 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); |
There was a problem hiding this comment.
🎯 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.
| <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} |
There was a problem hiding this comment.
🎯 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.
| {showDitherCompare && loadedImage && ( | ||
| <DitherCompareModal | ||
| image={loadedImage} | ||
| options={{ | ||
| columns: resolutionColumns, | ||
| characterSet, | ||
| customText: customGlyphs, | ||
| invert, | ||
| palette, | ||
| colorMode, | ||
| colorCount, | ||
| renderMode, | ||
| adjustments, | ||
| backgroundSeparation, | ||
| backgroundConfig, | ||
| colorTreatmentConfig, | ||
| }} |
There was a problem hiding this comment.
🎯 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.
| 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); |
There was a problem hiding this comment.
🗄️ 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.
Summary of Dithering Issues & Root Causes Found
source * (1 - strength) + dithered * strength). This reintroduced continuous non-quantized values into the tone field.glyphForTonewas non-linear:0.72after linear dithering distorted the dithered levels, causing adjacent levels to collapse to the same glyph or leaving unused gaps, completely mangling the dither pattern.Solutions & Renderer Changes
source + (threshold * strength) / levels), and for Error Diffusion dither, we scale the distributed error ((original - mapped) * strength), ensuring0%strength is identical to no dithering (pure quantization).glyphForToneto 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.Tests Added
test/renderer/dithering.test.tsverifying determinism, 1x1 edge cases, strength = 0, 0-1 tone range bounds, and distinct patterns on gradients for all ordered/diffusion ditherers.Performance Impact
Summary by CodeRabbit