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()