From 35325ceac0854ec2e4235b40751232ce58c8c1df Mon Sep 17 00:00:00 2001 From: "Farhan M." Date: Sat, 7 Nov 2026 20:29:04 +0600 Subject: [PATCH] Wait for Optimize; switching settings no longer re-runs anything Dropped images are now read and listed (thumbnail, size, role) but not made into anything until Optimize is pressed. Pressing it while images are still being read includes them once they're read. - CMS master is the default and comes first. The output isn't remembered between visits; the other settings still are. - Changing the output, role, quality or maximum width runs nothing. Images that need new files show "Ready", and Optimize counts them. - Each image keeps its last finished set per mode. Switching back to settings it was made with shows those files again without a second run. Sets are matched on the exact variants planned, so a change that wouldn't alter the files (e.g. a role with the same widths) keeps them. - While optimizing, the settings and row roles are locked, and Stop ends the run. Finished images keep their files; the rest go back to ready. - One button carries the next step, Optimize and then Download ZIP. It uses aria-disabled while busy, so focus stays on it. Copy and Save to folder appear once everything is made with the current settings. The e2e tests press Optimize. A new test covers waiting, locking, Stop and resuming, and the mode test checks that switching back needs no second run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STHRjFYWhkJBxSKG3tJdap --- README.md | 8 ++- e2e/a11y.spec.ts | 9 +++ e2e/app.spec.ts | 68 +++++++++++++++++-- e2e/decode.spec.ts | 6 +- e2e/focus.spec.ts | 2 + e2e/offline.spec.ts | 3 + src/App.svelte | 100 +++++++++++++++++++--------- src/lib/plan.test.ts | 25 +++---- src/lib/plan.ts | 2 +- src/ui/FileRow.svelte | 6 +- src/ui/InlineSelect.svelte | 5 +- src/ui/Segmented.svelte | 7 +- src/ui/Select.svelte | 7 +- src/ui/app.svelte.ts | 130 ++++++++++++++++++++++++++----------- src/ui/options.ts | 2 +- 15 files changed, 279 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 8d55f5c..1d3caa6 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,12 @@ Resize, compress and convert images for websites, in the browser. Files are proc ## Using it -Drop images or folders on the page, choose files, or paste. Then pick an output: +Drop images or folders on the page, choose files, or paste. They're listed, and nothing is made until you press **Optimize**. Pick an output first: +- **CMS master** (the default): one high-quality file per image, no wider than a maximum width. For a CMS or site builder that makes its own sizes (Wagtail, Payload, Webflow, Framer, …), so the stored original isn't needlessly large. - **Responsive set**: several widths of each image in modern formats, plus `` markup to paste into your site. For sites you build yourself. -- **CMS master**: one high-quality file per image, no wider than a maximum width. For a CMS or site builder that makes its own sizes (Wagtail, Payload, Webflow, Framer, …), so the stored original isn't needlessly large. + +The settings are locked while images are being optimized; _Stop_ ends the run. Changing a setting afterwards doesn't run anything: press Optimize again. If you switch back to settings an image was already optimized with, its files come back without another run. Download everything as a ZIP, or single files from a row. In Chrome and Edge, _Save to folder_ writes everything straight into a folder you pick, such as your site's `public/images`. Tap a thumbnail to compare an output with the original: drag the divider and switch between fit and actual pixels. @@ -54,7 +56,7 @@ Reads JPEG, PNG, WebP, AVIF, GIF (first frame) and TIFF. Other formats are hande Images are decoded, resized and encoded on your device, in the page. The Content Security Policy (`connect-src 'self'`) stops the page from sending data to any other server, and there are no analytics, trackers or third-party scripts. The font is served from the same site. -Your settings (output, role, quality, maximum width, path, markup, placeholder) are kept in your browser's local storage. A service worker keeps a copy of the app in the browser's cache, so it works offline; it only ever fetches the app's own files. +Your settings (role, quality, maximum width, path, markup, placeholder) are kept in your browser's local storage. The output isn't: each visit starts on CMS master. A service worker keeps a copy of the app in the browser's cache, so it works offline; it only ever fetches the app's own files. ## Development diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 9e6838c..010e30f 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -21,6 +21,12 @@ for (const colorScheme of ['light', 'dark'] as const) { // A failed row, for the contrast of its message. { name: 'broken.heic', mimeType: 'image/heic', buffer: Buffer.from('not a HEIC') }, ]) + // Listed, waiting for Optimize. + await expect(page.getByRole('cell', { name: 'Ready' })).toHaveCount(2, { timeout: 30_000 }) + expect(await violations(page), 'ready').toEqual([]) + + await page.getByRole('radio', { name: 'Responsive set' }).click() + await page.getByRole('button', { name: 'Optimize 2 images' }).click() // While processing: the progress bar needs a name. await expect(page.getByRole('progressbar', { name: 'Processing photo.jpg' })).toBeVisible() expect(await violations(page), 'processing').toEqual([]) @@ -42,6 +48,7 @@ test('focus stays on the list when rows are removed', async ({ page }) => { { name: 'b.jpg', mimeType: 'image/jpeg', buffer: await makeJpeg(page, 600, 400) }, { name: 'c.jpg', mimeType: 'image/jpeg', buffer: await makeJpeg(page, 600, 400) }, ]) + await page.getByRole('button', { name: 'Optimize 3 images' }).click() await expect(page.getByText(/^3 done/)).toBeVisible({ timeout: 90_000 }) const remove = (name: string) => page.getByRole('button', { name: `Remove ${name}` }) @@ -73,6 +80,8 @@ test('copying is announced', async ({ page, context }) => { mimeType: 'image/jpeg', buffer: await makeJpeg(page, 600, 400), }) + await page.getByRole('radio', { name: 'Responsive set' }).click() + await page.getByRole('button', { name: 'Optimize 1 image' }).click() await expect(page.getByText(/^1 done/)).toBeVisible({ timeout: 90_000 }) const announced = page.getByRole('status').filter({ hasText: 'Copied to the clipboard.' }) diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index b4e9b4b..a4b4950 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -16,6 +16,9 @@ test('makes a responsive set, a ZIP and a CMS master', async ({ page, context }) mimeType: 'image/jpeg', buffer: await makeJpeg(page, 1000, 600), }) + await expect(page.getByRole('radio', { name: 'CMS master' })).toBeChecked() + await page.getByRole('radio', { name: 'Responsive set' }).click() + await page.getByRole('button', { name: 'Optimize 1 image' }).click() // Content role, 1000 px source: 400, 800 and 1000 px, as AVIF + JPEG await expect(page.getByText(/^1 done/)).toBeVisible({ timeout: 90_000 }) @@ -33,7 +36,7 @@ test('makes a responsive set, a ZIP and a CMS master', async ({ page, context }) ' app.set({ mode })} /> {#if app.settings.mode === 'responsive'} @@ -183,12 +229,14 @@ label="Role" value={app.settings.role} options={ROLES} + disabled={optimizing} onchange={(role) => app.set({ role })} /> onchange(options[e.currentTarget.selectedIndex].value)} > {#each options as option (option.value)} diff --git a/src/ui/Segmented.svelte b/src/ui/Segmented.svelte index 288d519..bd5a68d 100644 --- a/src/ui/Segmented.svelte +++ b/src/ui/Segmented.svelte @@ -4,11 +4,13 @@ value, options, onchange, + disabled = false, }: { label: string value: T options: readonly { value: T; label: string }[] onchange: (value: T) => void + disabled?: boolean } = $props() @@ -22,12 +24,13 @@ type="button" role="radio" aria-checked={option.value === value} + {disabled} onclick={() => onchange(option.value)} class={[ - 'rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-blue-600 motion-reduce:transition-none', + 'rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-blue-600 disabled:opacity-50 motion-reduce:transition-none', option.value === value ? 'bg-white text-neutral-900 shadow-xs dark:bg-neutral-800 dark:text-neutral-100' - : 'text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100', + : 'text-neutral-600 enabled:hover:text-neutral-900 dark:text-neutral-400 dark:enabled:hover:text-neutral-100', ]} > {option.label} diff --git a/src/ui/Select.svelte b/src/ui/Select.svelte index 4d35869..391dff1 100644 --- a/src/ui/Select.svelte +++ b/src/ui/Select.svelte @@ -4,20 +4,23 @@ value, options, onchange, + disabled = false, }: { label: string value: T options: readonly { value: T; label: string }[] onchange: (value: T) => void + disabled?: boolean } = $props()