diff --git a/plans/260417-1125-image-importer-enhancements/phase-04-dithering-algorithms.md b/plans/260417-1125-image-importer-enhancements/phase-04-dithering-algorithms.md
index 67f372c..a6f446d 100644
--- a/plans/260417-1125-image-importer-enhancements/phase-04-dithering-algorithms.md
+++ b/plans/260417-1125-image-importer-enhancements/phase-04-dithering-algorithms.md
@@ -2,7 +2,7 @@
## Overview
- **Priority:** P2 (medium)
-- **Status:** Todo
+- **Status:** Done
- Add Atkinson, Jarvis, Stucki, Burkes, Sierra variants (error diffusion) and Bayer 2x2/4x4/8x8 ordered dithering so users can pick the visual style that fits their source.
## Key Insights
@@ -36,12 +36,12 @@ Refactor `src/lib/image-to-palette.js`:
6. Visual regression: snapshot-test a fixed gradient across methods.
## Todo
-- [ ] Refactor FS into generalized error-diffusion runner
-- [ ] Add Atkinson/Jarvis/Stucki/Burkes/Sierra/SierraLite kernels
-- [ ] Add Bayer 2/4/8 ordered dithering
-- [ ] UI dropdown + strength slider
-- [ ] CLI `--method`, keep `--dither` as alias
-- [ ] Tests
+- [x] Refactor FS into generalized error-diffusion runner (`runErrorDiffusion`)
+- [x] Add Atkinson/Jarvis/Burkes/Sierra/SierraLite kernels (Stucki dropped; close to Jarvis)
+- [x] Add Bayer 2/4/8 ordered dithering (`runOrderedDither`, SPREAD=48)
+- [x] UI dropdown (strength slider dropped as YAGNI for now — add if users ask)
+- [x] CLI `--dither-method`, kept `--dither` as floyd alias
+- [x] Tests (13 cases including all-methods smoke, kernel-weight sanity)
## Success Criteria
- Each method produces distinct, visibly reasonable output on a test gradient.
diff --git a/plans/260417-1125-image-importer-enhancements/plan.md b/plans/260417-1125-image-importer-enhancements/plan.md
index 5fdc602..6ea4612 100644
--- a/plans/260417-1125-image-importer-enhancements/plan.md
+++ b/plans/260417-1125-image-importer-enhancements/plan.md
@@ -18,7 +18,7 @@ Grow the in-app Image Import panel into a full pre-placement pipeline: resize, t
| 1 | [Resize controls](phase-01-resize-controls.md) | Done | Must-have — user explicitly asked |
| 2 | [Overlay preview on canvas](phase-02-overlay-preview.md) | Done | High — visualize alignment before spending credits |
| 3 | [Transforms (flip / rotate)](phase-03-transforms.md) | Done | Medium — quick fixes without re-editing source |
-| 4 | [More dithering algorithms](phase-04-dithering-algorithms.md) | Todo | Medium — different looks per source |
+| 4 | [More dithering algorithms](phase-04-dithering-algorithms.md) | Done | Medium — different looks per source |
| 5 | [Color correction sliders](phase-05-color-correction.md) | Todo | Medium — photos benefit most |
| 6 | [Skip-white + paint-transparent toggles](phase-06-skip-white.md) | Todo | Low — easy win, often useful for logos |
diff --git a/scripts/image-to-colors.js b/scripts/image-to-colors.js
index f208506..a23a9ed 100644
--- a/scripts/image-to-colors.js
+++ b/scripts/image-to-colors.js
@@ -23,6 +23,7 @@ const { values, positionals } = parseArgs({
output: { type: 'string', short: 'o' },
'alpha-threshold': { type: 'string', default: '128' },
dither: { type: 'boolean', default: false },
+ 'dither-method': { type: 'string' }, // see DITHER_METHODS; overrides --dither
width: { type: 'string' },
height: { type: 'string' },
method: { type: 'string', default: 'nearest' }, // nearest | bilinear | box
@@ -34,7 +35,7 @@ const { values, positionals } = parseArgs({
});
if (positionals.length < 1) {
- console.error('Usage: node scripts/image-to-colors.js [-o output.json] [--alpha-threshold 128] [--dither] [--width N] [--height N] [--method nearest|bilinear|box] [--flip-h] [--flip-v] [--rotate 0|90|180|270]');
+ console.error('Usage: node scripts/image-to-colors.js [-o output.json] [--alpha-threshold 128] [--dither|--dither-method ] [--width N] [--height N] [--method nearest|bilinear|box] [--flip-h] [--flip-v] [--rotate 0|90|180|270]');
process.exit(1);
}
@@ -84,7 +85,8 @@ const working = (outW === srcW && outH === srcH)
: resizeRgba(transformed.rgba, srcW, srcH, outW, outH, values.method);
// sharp raw buffer has channels=4 after ensureAlpha; same layout as Canvas ImageData.
-const indices = rgbaToPalette(working, outW, outH, { alphaThreshold, dither: values.dither });
+const ditherMethod = values['dither-method'] ?? (values.dither ? 'floyd' : 'none');
+const indices = rgbaToPalette(working, outW, outH, { alphaThreshold, method: ditherMethod });
const pixels = Array.from(indices);
const opaque = pixels.reduce((n, p) => n + (p >= 0 ? 1 : 0), 0);
@@ -93,4 +95,5 @@ const transformNote = (values['flip-h'] || values['flip-v'] || rotation !== 0)
? ` [transform: ${[values['flip-h'] && 'flipH', values['flip-v'] && 'flipV', rotation !== 0 && `rot${rotation}`].filter(Boolean).join('+')}]`
: '';
const resizeNote = (outW !== srcW || outH !== srcH) ? ` (resized from ${srcW}x${srcH} via ${values.method})` : '';
-console.log(`Wrote ${output}: ${outW}x${outH}${resizeNote}${transformNote}, ${opaque}/${pixels.length} opaque pixels${values.dither ? ' [dithered]' : ''}`);
+const ditherNote = ditherMethod !== 'none' ? ` [dither: ${ditherMethod}]` : '';
+console.log(`Wrote ${output}: ${outW}x${outH}${resizeNote}${transformNote}, ${opaque}/${pixels.length} opaque pixels${ditherNote}`);
diff --git a/src/client/components/ImageImporter.svelte b/src/client/components/ImageImporter.svelte
index aa770f1..7a0e457 100644
--- a/src/client/components/ImageImporter.svelte
+++ b/src/client/components/ImageImporter.svelte
@@ -1,6 +1,6 @@